14 Commits

Author SHA1 Message Date
genki
2b5f761d25 Oh yes - Thats how we do
No default params for KSP complainer fuck

UI sweeps
2026-01-10 00:08:04 -05:00
genki
52ea64f29a Oh yes - Thats how we do
No default params for KSP complainer fuck

UI sweeps
2026-01-09 19:59:44 -05:00
genki
51fdfbf3d6 Improved Training Screen and underlying
Added diagnostic view model with flag for picture detection but broke fucking everything meassing with tagDAO. au demain
2026-01-08 00:02:27 -05:00
genki
6ce115baa9 Bradeth_v1
UI improvement sweep
Underlying 'train models' backend functionality, dao and room db.
Mlmodule Hilt DI
2026-01-07 00:44:11 -05:00
genki
6734c343cc TrainScreen / FacePicker / Sanity Checking input training data (dupes, multi faces) 2026-01-02 02:20:57 -05:00
genki
22c25d5ced TODO - end of time - need to revisit anlysis results window - broke it adding the uh faePicker (needs to go in AppRoutes) 2026-01-01 01:30:08 -05:00
genki
dba64b89b6 face detection + multi faces check
filtering before crop prompt - do we need to have user crop photos with only one face?
2026-01-01 01:02:42 -05:00
genki
3f15bfabc1 Cleaner - UI ALmost and Room Photo Ingestion 2025-12-26 01:26:51 -05:00
genki
0f7f4a4201 Cleaner - Needs UI rebuild from Master TBD 2025-12-25 22:18:58 -05:00
genki
0d34a2510b Mess - Crash on boot - Backend ?? 2025-12-25 00:40:57 -05:00
genki
c458e08075 Correct schema
Meaningful queries
Proper transactional reads
2025-12-24 22:48:34 -05:00
genki
c10cbf373f Working Gallery and Repo - Earlydays! 2025-12-20 18:27:09 -05:00
genki
91f6327c31 CheckPoint save for adding 'Tour' screen, and PhotoData and PhotoViewModels 2025-12-20 18:27:09 -05:00
genki
52fa755a3f Working Gallery and Repo - Earlydays! 2025-12-20 17:57:01 -05:00
81 changed files with 11933 additions and 271 deletions

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
SherpAI2

View File

@@ -4,6 +4,14 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-08T02:44:48.809354959Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/genki/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

1
.idea/gradle.xml generated
View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings"> <component name="GradleSettings">
<option name="linkedExternalProjectsSettings"> <option name="linkedExternalProjectsSettings">
<GradleProjectSettings> <GradleProjectSettings>

View File

@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

1
.idea/misc.xml generated
View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@@ -0,0 +1,4 @@
kotlin version: 2.0.21
error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
1. Kotlin compile daemon is ready

0
app/PersonEntity Normal file
View File

View File

@@ -1,75 +1,88 @@
// build.gradle.kts (Module: :app)
plugins { plugins {
// 1. Core Android and Kotlin plugins (MUST be first) alias(libs.plugins.android.application)
id("com.android.application") alias(libs.plugins.kotlin.android)
kotlin("android") alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
id("org.jetbrains.kotlin.plugin.compose") // Note: No version is specified here alias(libs.plugins.hilt.android)
} }
android { android {
// 2. Android Configuration
namespace = "com.placeholder.sherpai2" namespace = "com.placeholder.sherpai2"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.placeholder.sherpai2" applicationId = "com.placeholder.sherpai2"
minSdk = 24 minSdk = 25
targetSdk = 34 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
// 3. Kotlin & Java Settings buildTypes {
compileOptions { release {
sourceCompatibility = JavaVersion.VERSION_1_8 isMinifyEnabled = false
targetCompatibility = JavaVersion.VERSION_1_8 proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
kotlinOptions { }
jvmTarget = "1.8"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
} }
// 4. Jetpack Compose Configuration (Crucial!)
buildFeatures { buildFeatures {
compose = true compose = true
} }
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8" // Must match your Kotlin version
}
} }
dependencies { dependencies {
// --- CORE ANDROID & LIFECYCLE --- // Core & Lifecycle
implementation("androidx.core:core-ktx:1.12.0") implementation(libs.androidx.core.ktx)
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0") implementation(libs.androidx.lifecycle.runtime.ktx)
implementation("androidx.activity:activity-compose:1.8.2") // Fixes 'activity' ref error implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
// --- JETPACK COMPOSE UI (Material 3) --- // Compose
implementation("androidx.compose.ui:ui") implementation(platform(libs.androidx.compose.bom))
implementation("androidx.compose.ui:ui-graphics") implementation(libs.androidx.compose.ui)
implementation("androidx.compose.ui:ui-tooling-preview") implementation(libs.androidx.compose.ui.graphics)
implementation("androidx.compose.material3:material3") // Fixes 'material3' ref error implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons)
debugImplementation(libs.androidx.compose.ui.tooling)
// --- COMPOSE ICONS (Fixes 'material' and 'Icons' ref errors) --- // Hilt DI
// Uses direct string to avoid Version Catalog conflicts implementation(libs.hilt.android)
implementation("androidx.compose.material:material-icons-extended:1.6.0") ksp(libs.hilt.compiler)
implementation(libs.androidx.hilt.navigation.compose)
// --- STATE MANAGEMENT / COROUTINES --- // Navigation
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0") implementation(libs.androidx.navigation.compose)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// --- TESTING --- // Room Database
testImplementation("junit:junit:4.13.2") implementation(libs.room.runtime)
androidTestImplementation("androidx.test.ext:junit:1.1.5") implementation(libs.room.ktx)
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") ksp(libs.room.compiler)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
implementation("androidx.compose.foundation:foundation:1.6.0") // Use your current Compose version // Coil Images
implementation("androidx.compose.material3:material3:1.2.1") // <-- Fix/Reconfirm Material 3 implementation(libs.coil.compose)
// ML Kit
implementation(libs.mlkit.face.detection)
implementation(libs.kotlinx.coroutines.play.services)
//Face Rec
implementation(libs.tensorflow.lite)
implementation(libs.tensorflow.lite.support)
// Optional: GPU acceleration
implementation(libs.tensorflow.lite.gpu)
// Gson for storing FloatArrays in Room
implementation(libs.gson)
} }

View File

@@ -10,7 +10,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SherpAI2"> android:theme="@style/Theme.SherpAI2"
android:name=".SherpAIApplication">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -23,5 +24,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
</manifest> </manifest>

View File

@@ -1,27 +1,91 @@
package com.placeholder.sherpai2 package com.placeholder.sherpai2
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen import androidx.core.content.ContextCompat
import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.ui.presentation.MainScreen
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject
lateinit var imageRepository: ImageRepository
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
setContent { setContent {
// Assume you have a Theme file named SherpAI2Theme (standard for new projects) SherpAI2Theme {
// Replace with your actual project theme if different var hasPermission by remember {
MaterialTheme { mutableStateOf(
Surface( ContextCompat.checkSelfPermission(this@MainActivity, storagePermission) ==
PackageManager.PERMISSION_GRANTED
)
}
var isIngesting by remember { mutableStateOf(false) }
var imagesIngested by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
hasPermission = granted
}
// Logic: Handle the flow of Permission -> Ingestion
LaunchedEffect(hasPermission) {
if (hasPermission) {
if (!imagesIngested && !isIngesting) {
isIngesting = true
imageRepository.ingestImages()
imagesIngested = true
isIngesting = false
}
} else {
permissionLauncher.launch(storagePermission)
}
}
// UI State Mapping
Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background contentAlignment = Alignment.Center
) { ) {
// Launch the main navigation UI when {
MainScreen() hasPermission && imagesIngested -> {
MainScreen()
}
hasPermission && isIngesting -> {
// Show a loader so you know it's working!
CircularProgressIndicator()
}
else -> {
Text("Please grant storage permission to continue.")
}
}
} }
} }
} }

View File

@@ -0,0 +1,7 @@
package com.placeholder.sherpai2
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class SherpAIApplication : Application()

View File

@@ -0,0 +1,49 @@
package com.placeholder.sherpai2.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.placeholder.sherpai2.data.local.dao.*
import com.placeholder.sherpai2.data.local.entity.*
/**
* AppDatabase - Complete database for SherpAI2
*
* ENTITIES:
* - YOUR EXISTING: Image, Tag, Event, junction tables
* - NEW: PersonEntity (people in your app)
* - NEW: FaceModelEntity (face embeddings, links to PersonEntity)
* - NEW: PhotoFaceTagEntity (face detections, links to ImageEntity + FaceModelEntity)
*/
@Database(
entities = [
// ===== YOUR EXISTING ENTITIES =====
ImageEntity::class,
TagEntity::class,
EventEntity::class,
ImageTagEntity::class,
ImageEventEntity::class,
// ===== NEW ENTITIES =====
PersonEntity::class, // NEW: People
FaceModelEntity::class, // NEW: Face embeddings
PhotoFaceTagEntity::class // NEW: Face tags
],
version = 5,
exportSchema = false
)
// No TypeConverters needed - embeddings stored as strings
abstract class AppDatabase : RoomDatabase() {
// ===== YOUR EXISTING DAOs =====
abstract fun imageDao(): ImageDao
abstract fun tagDao(): TagDao
abstract fun eventDao(): EventDao
abstract fun imageTagDao(): ImageTagDao
abstract fun imageEventDao(): ImageEventDao
abstract fun imageAggregateDao(): ImageAggregateDao
// ===== NEW DAOs =====
abstract fun personDao(): PersonDao // NEW: Manage people
abstract fun faceModelDao(): FaceModelDao // NEW: Manage face embeddings
abstract fun photoFaceTagDao(): PhotoFaceTagDao // NEW: Manage face tags
}

View File

@@ -0,0 +1,26 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.EventEntity
@Dao
interface EventDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(event: EventEntity)
/**
* Find events covering a timestamp.
*
* This is the backbone of auto-tagging by date.
*/
@Query("""
SELECT * FROM events
WHERE :timestamp BETWEEN startDate AND endDate
AND isHidden = 0
""")
suspend fun findEventsForTimestamp(timestamp: Long): List<EventEntity>
}

View File

@@ -0,0 +1,44 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import com.placeholder.sherpai2.data.local.entity.FaceModelEntity
/**
* FaceModelDao - Manages face recognition models
*
* PRIMARY KEY TYPE: String (UUID)
* FOREIGN KEY: personId (String)
*/
@Dao
interface FaceModelDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFaceModel(faceModel: FaceModelEntity): Long // Row ID
@Update
suspend fun updateFaceModel(faceModel: FaceModelEntity)
@Query("UPDATE face_models SET lastUsed = :timestamp WHERE id = :faceModelId")
suspend fun updateLastUsed(faceModelId: String, timestamp: Long)
@Query("SELECT * FROM face_models WHERE id = :faceModelId")
suspend fun getFaceModelById(faceModelId: String): FaceModelEntity?
@Query("SELECT * FROM face_models WHERE personId = :personId AND isActive = 1")
suspend fun getFaceModelByPersonId(personId: String): FaceModelEntity?
@Query("SELECT * FROM face_models WHERE isActive = 1 ORDER BY lastUsed DESC")
suspend fun getAllActiveFaceModels(): List<FaceModelEntity>
@Query("SELECT * FROM face_models WHERE isActive = 1 ORDER BY lastUsed DESC")
fun getAllActiveFaceModelsFlow(): Flow<List<FaceModelEntity>>
@Query("DELETE FROM face_models WHERE id = :faceModelId")
suspend fun deleteFaceModelById(faceModelId: String)
@Query("UPDATE face_models SET isActive = 0 WHERE id = :faceModelId")
suspend fun deactivateFaceModel(faceModelId: String)
@Query("SELECT COUNT(*) FROM face_models WHERE isActive = 1")
suspend fun getActiveFaceModelCount(): Int
}

View File

@@ -0,0 +1,48 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import kotlinx.coroutines.flow.Flow
@Dao
interface ImageAggregateDao {
/**
* Observe a fully-hydrated image object.
*/
@Transaction
@Query("""
SELECT * FROM images
WHERE imageId = :imageId
""")
fun observeImageWithEverything(
imageId: String
): Flow<ImageWithEverything>
/**
* Observe all images.
*/
@Transaction
@Query("""
SELECT * FROM images
ORDER BY capturedAt DESC
""")
fun observeAllImagesWithEverything(): Flow<List<ImageWithEverything>>
/**
* Observe images filtered by tag value.
*
* Joins images -> image_tags -> tags
*/
@Transaction
@Query("""
SELECT images.* FROM images
INNER JOIN image_tags ON images.imageId = image_tags.imageId
INNER JOIN tags ON tags.tagId = image_tags.tagId
WHERE tags.value = :tag
ORDER BY images.capturedAt DESC
""")
fun observeImagesWithTag(tag: String): Flow<List<ImageWithEverything>>
}

View File

@@ -0,0 +1,75 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import kotlinx.coroutines.flow.Flow
@Dao
interface ImageDao {
/**
* Insert images.
*
* IGNORE prevents duplicate insertion
* when sha256 or imageUri already exists.
*/
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertImages(images: List<ImageEntity>)
/**
* Get image by ID.
*/
@Query("SELECT * FROM images WHERE imageId = :imageId")
suspend fun getImageById(imageId: String): ImageEntity?
/**
* Stream images ordered by capture time (newest first).
*
* Flow is critical:
* - UI auto-updates
* - No manual refresh
*/
@Query("""
SELECT * FROM images
ORDER BY capturedAt DESC
""")
fun observeAllImages(): Flow<List<ImageEntity>>
/**
* Fetch images in a time range.
* Used for:
* - event auto-assignment
* - timeline views
*/
@Query("""
SELECT * FROM images
WHERE capturedAt BETWEEN :start AND :end
ORDER BY capturedAt ASC
""")
suspend fun getImagesInRange(
start: Long,
end: Long
): List<ImageEntity>
@Transaction
@Query("SELECT * FROM images ORDER BY capturedAt DESC LIMIT :limit")
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
@Query("SELECT COUNT(*) > 0 FROM images WHERE sha256 = :sha256")
suspend fun existsBySha256(sha256: String): Boolean
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(image: ImageEntity)
/**
* Get images by list of IDs.
* FIXED: Changed from List<Long> to List<String> to match ImageEntity.imageId type
*/
@Query("SELECT * FROM images WHERE imageId IN (:imageIds)")
suspend fun getImagesByIds(imageIds: List<String>): List<ImageEntity>
}

View File

@@ -0,0 +1,23 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity
@Dao
interface ImageEventDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ImageEventEntity)
/**
* Images associated with an event.
*/
@Query("""
SELECT imageId FROM image_events
WHERE eventId = :eventId
""")
suspend fun findImagesForEvent(eventId: String): List<String>
}

View File

@@ -0,0 +1,47 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ImageTagDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(imageTag: ImageTagEntity)
@Query("""
SELECT * FROM image_tags
WHERE imageId = :imageId
AND visibility != 'HIDDEN'
""")
fun observeTagsForImage(imageId: String): Flow<List<ImageTagEntity>>
/**
* FIXED: Removed default parameter
*/
@Query("""
SELECT imageId FROM image_tags
WHERE tagId = :tagId
AND visibility = 'PUBLIC'
AND confidence >= :minConfidence
""")
suspend fun findImagesByTag(
tagId: String,
minConfidence: Float
): List<String>
@Transaction
@Query("""
SELECT t.*
FROM tags t
INNER JOIN image_tags it ON t.tagId = it.tagId
WHERE it.imageId = :imageId AND it.visibility = 'PUBLIC'
""")
fun getTagsForImage(imageId: String): Flow<List<TagEntity>>
}

View File

@@ -0,0 +1,51 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.*
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface PersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(person: PersonEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(persons: List<PersonEntity>)
@Update
suspend fun update(person: PersonEntity)
/**
* FIXED: Removed default parameter
*/
@Query("UPDATE persons SET updatedAt = :timestamp WHERE id = :personId")
suspend fun updateTimestamp(personId: String, timestamp: Long)
@Delete
suspend fun delete(person: PersonEntity)
@Query("DELETE FROM persons WHERE id = :personId")
suspend fun deleteById(personId: String)
@Query("SELECT * FROM persons WHERE id = :personId")
suspend fun getPersonById(personId: String): PersonEntity?
@Query("SELECT * FROM persons WHERE id IN (:personIds)")
suspend fun getPersonsByIds(personIds: List<String>): List<PersonEntity>
@Query("SELECT * FROM persons ORDER BY name ASC")
suspend fun getAllPersons(): List<PersonEntity>
@Query("SELECT * FROM persons ORDER BY name ASC")
fun getAllPersonsFlow(): Flow<List<PersonEntity>>
@Query("SELECT * FROM persons WHERE name LIKE '%' || :query || '%' ORDER BY name ASC")
suspend fun searchByName(query: String): List<PersonEntity>
@Query("SELECT COUNT(*) FROM persons")
suspend fun getPersonCount(): Int
@Query("SELECT EXISTS(SELECT 1 FROM persons WHERE id = :personId)")
suspend fun personExists(personId: String): Boolean
}

View File

@@ -0,0 +1,91 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.*
import kotlinx.coroutines.flow.Flow
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
@Dao
interface PhotoFaceTagDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTag(tag: PhotoFaceTagEntity): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTags(tags: List<PhotoFaceTagEntity>)
@Update
suspend fun updateTag(tag: PhotoFaceTagEntity)
/**
* FIXED: Removed default parameter
*/
@Query("UPDATE photo_face_tags SET verifiedByUser = 1, verifiedAt = :timestamp WHERE id = :tagId")
suspend fun markTagAsVerified(tagId: String, timestamp: Long)
// ===== QUERY BY IMAGE =====
@Query("SELECT * FROM photo_face_tags WHERE imageId = :imageId")
suspend fun getTagsForImage(imageId: String): List<PhotoFaceTagEntity>
@Query("SELECT COUNT(*) FROM photo_face_tags WHERE imageId = :imageId")
suspend fun getFaceCountForImage(imageId: String): Int
@Query("SELECT EXISTS(SELECT 1 FROM photo_face_tags WHERE imageId = :imageId AND faceModelId = :faceModelId)")
suspend fun imageHasPerson(imageId: String, faceModelId: String): Boolean
// ===== QUERY BY FACE MODEL =====
@Query("SELECT DISTINCT imageId FROM photo_face_tags WHERE faceModelId = :faceModelId ORDER BY detectedAt DESC")
suspend fun getImageIdsForFaceModel(faceModelId: String): List<String>
@Query("SELECT DISTINCT imageId FROM photo_face_tags WHERE faceModelId = :faceModelId ORDER BY detectedAt DESC")
fun getImageIdsForFaceModelFlow(faceModelId: String): Flow<List<String>>
@Query("SELECT faceModelId, COUNT(DISTINCT imageId) as photoCount FROM photo_face_tags GROUP BY faceModelId")
suspend fun getPhotoCountPerFaceModel(): List<FaceModelPhotoCount>
@Query("SELECT * FROM photo_face_tags WHERE faceModelId = :faceModelId ORDER BY detectedAt DESC")
suspend fun getAllTagsForFaceModel(faceModelId: String): List<PhotoFaceTagEntity>
// ===== DELETE =====
@Delete
suspend fun deleteTag(tag: PhotoFaceTagEntity)
@Query("DELETE FROM photo_face_tags WHERE id = :tagId")
suspend fun deleteTagById(tagId: String)
@Query("DELETE FROM photo_face_tags WHERE faceModelId = :faceModelId")
suspend fun deleteTagsForFaceModel(faceModelId: String)
@Query("DELETE FROM photo_face_tags WHERE imageId = :imageId")
suspend fun deleteTagsForImage(imageId: String)
// ===== STATISTICS =====
/**
* FIXED: Removed default parameter
*/
@Query("SELECT * FROM photo_face_tags WHERE confidence < :threshold ORDER BY confidence ASC")
suspend fun getLowConfidenceTags(threshold: Float): List<PhotoFaceTagEntity>
@Query("SELECT * FROM photo_face_tags WHERE verifiedByUser = 0 ORDER BY detectedAt DESC")
suspend fun getUnverifiedTags(): List<PhotoFaceTagEntity>
@Query("SELECT COUNT(*) FROM photo_face_tags WHERE verifiedByUser = 0")
suspend fun getUnverifiedTagCount(): Int
@Query("SELECT AVG(confidence) FROM photo_face_tags WHERE faceModelId = :faceModelId")
suspend fun getAverageConfidenceForFaceModel(faceModelId: String): Float?
/**
* FIXED: Removed default parameter
*/
@Query("SELECT * FROM photo_face_tags ORDER BY detectedAt DESC LIMIT :limit")
suspend fun getRecentlyDetectedFaces(limit: Int): List<PhotoFaceTagEntity>
}
data class FaceModelPhotoCount(
val faceModelId: String,
val photoCount: Int
)

View File

@@ -0,0 +1,221 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
import kotlinx.coroutines.flow.Flow
/**
* TagDao - Tag management with face recognition integration
*
* NO DEFAULT PARAMETERS - Room doesn't support them in @Query methods
*/
@Dao
interface TagDao {
// ======================
// BASIC OPERATIONS
// ======================
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity): Long
@Query("SELECT * FROM tags WHERE value = :value LIMIT 1")
suspend fun getByValue(value: String): TagEntity?
@Query("SELECT * FROM tags WHERE tagId = :tagId")
suspend fun getById(tagId: String): TagEntity?
@Query("SELECT * FROM tags ORDER BY value ASC")
suspend fun getAll(): List<TagEntity>
@Query("SELECT * FROM tags ORDER BY value ASC")
fun getAllFlow(): Flow<List<TagEntity>>
@Query("SELECT * FROM tags WHERE type = :type ORDER BY value ASC")
suspend fun getByType(type: String): List<TagEntity>
@Query("DELETE FROM tags WHERE tagId = :tagId")
suspend fun delete(tagId: String)
// ======================
// STATISTICS (returns TagWithUsage)
// ======================
/**
* Get most used tags WITH usage counts
*
* @param limit Maximum number of tags to return
*/
@Query("""
SELECT t.tagId, t.type, t.value, t.createdAt,
COUNT(it.imageId) as usage_count
FROM tags t
LEFT JOIN image_tags it ON t.tagId = it.tagId
GROUP BY t.tagId
ORDER BY usage_count DESC
LIMIT :limit
""")
suspend fun getMostUsedTags(limit: Int): List<TagWithUsage>
/**
* Get tag usage count
*/
@Query("""
SELECT COUNT(DISTINCT it.imageId)
FROM image_tags it
WHERE it.tagId = :tagId
""")
suspend fun getTagUsageCount(tagId: String): Int
// ======================
// PERSON INTEGRATION
// ======================
/**
* Get all tags used for images containing a specific person
*/
@Query("""
SELECT DISTINCT t.* FROM tags t
INNER JOIN image_tags it ON t.tagId = it.tagId
INNER JOIN photo_face_tags pft ON it.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE fm.personId = :personId
ORDER BY t.value ASC
""")
suspend fun getTagsForPerson(personId: String): List<TagEntity>
/**
* Get images that have both a specific tag AND contain a specific person
*/
@Query("""
SELECT DISTINCT i.* FROM images i
INNER JOIN image_tags it ON i.imageId = it.imageId
INNER JOIN photo_face_tags pft ON i.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE it.tagId = :tagId AND fm.personId = :personId
ORDER BY i.capturedAt DESC
""")
suspend fun getImagesWithTagAndPerson(
tagId: String,
personId: String
): List<ImageEntity>
/**
* Get images with tag and person as Flow
*/
@Query("""
SELECT DISTINCT i.* FROM images i
INNER JOIN image_tags it ON i.imageId = it.imageId
INNER JOIN photo_face_tags pft ON i.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE it.tagId = :tagId AND fm.personId = :personId
ORDER BY i.capturedAt DESC
""")
fun getImagesWithTagAndPersonFlow(
tagId: String,
personId: String
): Flow<List<ImageEntity>>
/**
* Count images with tag and person
*/
@Query("""
SELECT COUNT(DISTINCT i.imageId) FROM images i
INNER JOIN image_tags it ON i.imageId = it.imageId
INNER JOIN photo_face_tags pft ON i.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE it.tagId = :tagId AND fm.personId = :personId
""")
suspend fun countImagesWithTagAndPerson(
tagId: String,
personId: String
): Int
// ======================
// AUTO-SUGGESTIONS
// ======================
/**
* Suggest tags based on person's relationship
*
* @param limit Maximum number of suggestions
*/
@Query("""
SELECT DISTINCT t.* FROM tags t
INNER JOIN image_tags it ON t.tagId = it.tagId
INNER JOIN photo_face_tags pft ON it.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
INNER JOIN persons p ON fm.personId = p.id
WHERE p.relationship = :relationship
AND p.id != :excludePersonId
GROUP BY t.tagId
ORDER BY COUNT(it.imageId) DESC
LIMIT :limit
""")
suspend fun suggestTagsBasedOnRelationship(
relationship: String,
excludePersonId: String,
limit: Int
): List<TagEntity>
/**
* Get tags commonly used with this tag
*
* @param limit Maximum number of related tags
*/
@Query("""
SELECT DISTINCT t2.* FROM tags t2
INNER JOIN image_tags it2 ON t2.tagId = it2.tagId
WHERE it2.imageId IN (
SELECT it1.imageId FROM image_tags it1
WHERE it1.tagId = :tagId
)
AND t2.tagId != :tagId
GROUP BY t2.tagId
ORDER BY COUNT(it2.imageId) DESC
LIMIT :limit
""")
suspend fun getRelatedTags(
tagId: String,
limit: Int
): List<TagEntity>
// ======================
// SEARCH
// ======================
/**
* Search tags by value (partial match)
*
* @param limit Maximum number of results
*/
@Query("""
SELECT * FROM tags
WHERE value LIKE '%' || :query || '%'
ORDER BY value ASC
LIMIT :limit
""")
suspend fun searchTags(query: String, limit: Int): List<TagEntity>
/**
* Search tags with usage count
*
* @param limit Maximum number of results
*/
@Query("""
SELECT t.tagId, t.type, t.value, t.createdAt,
COUNT(it.imageId) as usage_count
FROM tags t
LEFT JOIN image_tags it ON t.tagId = it.tagId
WHERE t.value LIKE '%' || :query || '%'
GROUP BY t.tagId
ORDER BY usage_count DESC, t.value ASC
LIMIT :limit
""")
suspend fun searchTagsWithUsage(query: String, limit: Int): List<TagWithUsage>
}

View File

@@ -0,0 +1,44 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Represents a meaningful event spanning a time range.
*
* Events allow auto-association of images by timestamp.
*/
@Entity(
tableName = "events",
indices = [
Index(value = ["startDate"]),
Index(value = ["endDate"])
]
)
data class EventEntity(
@PrimaryKey
val eventId: String,
val name: String,
/**
* Inclusive start date (UTC millis).
*/
val startDate: Long,
/**
* Inclusive end date (UTC millis).
*/
val endDate: Long,
val location: String?,
/**
* 0.0 1.0 importance weight
*/
val importance: Float,
val isHidden: Boolean
)

View File

@@ -0,0 +1,231 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
/**
* PersonEntity - NO DEFAULT VALUES for KSP compatibility
*/
@Entity(
tableName = "persons",
indices = [Index(value = ["name"])]
)
data class PersonEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String, // ← No default
@ColumnInfo(name = "name")
val name: String,
@ColumnInfo(name = "dateOfBirth")
val dateOfBirth: Long?,
@ColumnInfo(name = "relationship")
val relationship: String?,
@ColumnInfo(name = "createdAt")
val createdAt: Long, // ← No default
@ColumnInfo(name = "updatedAt")
val updatedAt: Long // ← No default
) {
companion object {
fun create(
name: String,
dateOfBirth: Long? = null,
relationship: String? = null
): PersonEntity {
val now = System.currentTimeMillis()
return PersonEntity(
id = UUID.randomUUID().toString(),
name = name,
dateOfBirth = dateOfBirth,
relationship = relationship,
createdAt = now,
updatedAt = now
)
}
}
fun getAge(): Int? {
if (dateOfBirth == null) return null
val now = System.currentTimeMillis()
val ageInMillis = now - dateOfBirth
return (ageInMillis / (1000L * 60 * 60 * 24 * 365)).toInt()
}
fun getRelationshipEmoji(): String {
return when (relationship) {
"Family" -> "👨‍👩‍👧‍👦"
"Friend" -> "🤝"
"Partner" -> "❤️"
"Child" -> "👶"
"Parent" -> "👪"
"Sibling" -> "👫"
"Colleague" -> "💼"
else -> "👤"
}
}
}
/**
* FaceModelEntity - NO DEFAULT VALUES
*/
@Entity(
tableName = "face_models",
foreignKeys = [
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["personId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["personId"], unique = true)]
)
data class FaceModelEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String, // ← No default
@ColumnInfo(name = "personId")
val personId: String,
@ColumnInfo(name = "embedding")
val embedding: String,
@ColumnInfo(name = "trainingImageCount")
val trainingImageCount: Int,
@ColumnInfo(name = "averageConfidence")
val averageConfidence: Float,
@ColumnInfo(name = "createdAt")
val createdAt: Long, // ← No default
@ColumnInfo(name = "updatedAt")
val updatedAt: Long, // ← No default
@ColumnInfo(name = "lastUsed")
val lastUsed: Long?,
@ColumnInfo(name = "isActive")
val isActive: Boolean
) {
companion object {
fun create(
personId: String,
embeddingArray: FloatArray,
trainingImageCount: Int,
averageConfidence: Float
): FaceModelEntity {
val now = System.currentTimeMillis()
return FaceModelEntity(
id = UUID.randomUUID().toString(),
personId = personId,
embedding = embeddingArray.joinToString(","),
trainingImageCount = trainingImageCount,
averageConfidence = averageConfidence,
createdAt = now,
updatedAt = now,
lastUsed = null,
isActive = true
)
}
}
fun getEmbeddingArray(): FloatArray {
return embedding.split(",").map { it.toFloat() }.toFloatArray()
}
}
/**
* PhotoFaceTagEntity - NO DEFAULT VALUES
*/
@Entity(
tableName = "photo_face_tags",
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = FaceModelEntity::class,
parentColumns = ["id"],
childColumns = ["faceModelId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["imageId"]),
Index(value = ["faceModelId"]),
Index(value = ["imageId", "faceModelId"])
]
)
data class PhotoFaceTagEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String, // ← No default
@ColumnInfo(name = "imageId")
val imageId: String,
@ColumnInfo(name = "faceModelId")
val faceModelId: String,
@ColumnInfo(name = "boundingBox")
val boundingBox: String,
@ColumnInfo(name = "confidence")
val confidence: Float,
@ColumnInfo(name = "embedding")
val embedding: String,
@ColumnInfo(name = "detectedAt")
val detectedAt: Long, // ← No default
@ColumnInfo(name = "verifiedByUser")
val verifiedByUser: Boolean,
@ColumnInfo(name = "verifiedAt")
val verifiedAt: Long?
) {
companion object {
fun create(
imageId: String,
faceModelId: String,
boundingBox: android.graphics.Rect,
confidence: Float,
faceEmbedding: FloatArray
): PhotoFaceTagEntity {
return PhotoFaceTagEntity(
id = UUID.randomUUID().toString(),
imageId = imageId,
faceModelId = faceModelId,
boundingBox = "${boundingBox.left},${boundingBox.top},${boundingBox.right},${boundingBox.bottom}",
confidence = confidence,
embedding = faceEmbedding.joinToString(","),
detectedAt = System.currentTimeMillis(),
verifiedByUser = false,
verifiedAt = null
)
}
}
fun getBoundingBox(): android.graphics.Rect {
val parts = boundingBox.split(",").map { it.toInt() }
return android.graphics.Rect(parts[0], parts[1], parts[2], parts[3])
}
fun getEmbeddingArray(): FloatArray {
return embedding.split(",").map { it.toFloat() }.toFloatArray()
}
}

View File

@@ -0,0 +1,55 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
/**
* Represents a single image on the device.
*
* This entity is intentionally immutable:
* - imageUri identifies where the image lives
* - sha256 prevents duplicates
* - capturedAt is the EXIF timestamp
*
* This table should be append-only.
*/
@Entity(
tableName = "images",
indices = [
Index(value = ["imageUri"], unique = true),
Index(value = ["sha256"], unique = true),
Index(value = ["capturedAt"])
]
)
data class ImageEntity(
@PrimaryKey
val imageId: String,
val imageUri: String,
/**
* Cryptographic hash of image bytes.
* Used for deduplication and re-indexing.
*/
val sha256: String,
/**
* EXIF timestamp (UTC millis).
*/
val capturedAt: Long,
/**
* When this image was indexed into the app.
*/
val ingestedAt: Long,
val width: Int,
val height: Int,
/**
* CAMERA | SCREENSHOT | IMPORTED
*/
val source: String
)

View File

@@ -0,0 +1,42 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "image_events",
primaryKeys = ["imageId", "eventId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = EventEntity::class,
parentColumns = ["eventId"],
childColumns = ["eventId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("eventId")
]
)
data class ImageEventEntity(
val imageId: String,
val eventId: String,
/**
* AUTO | MANUAL
*/
val source: String,
/**
* User override flag.
*/
val override: Boolean
)

View File

@@ -0,0 +1,56 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
/**
* Join table linking images to tags.
*
* This is NOT optional.
* Do not inline tag lists on ImageEntity.
*/
@Entity(
tableName = "image_tags",
primaryKeys = ["imageId", "tagId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["tagId"],
childColumns = ["tagId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("tagId"),
Index("imageId")
]
)
data class ImageTagEntity(
val imageId: String,
val tagId: String,
/**
* AUTO | MANUAL
*/
val source: String,
/**
* ML confidence (01).
*/
val confidence: Float,
/**
* PUBLIC | PRIVATE | HIDDEN
*/
val visibility: String,
val createdAt: Long
)

View File

@@ -0,0 +1,143 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
/**
* Tag type constants - MUST be defined BEFORE TagEntity
* to avoid KSP initialization order issues
*/
object TagType {
const val GENERIC = "GENERIC" // User tags
const val SYSTEM = "SYSTEM" // AI/auto tags
const val HIDDEN = "HIDDEN" // Internal
}
/**
* Common system tag values
*/
object SystemTags {
const val HAS_FACES = "has_faces"
const val MULTIPLE_PEOPLE = "multiple_people"
const val LANDSCAPE = "landscape"
const val PORTRAIT = "portrait"
const val LOW_QUALITY = "low_quality"
const val BLURRY = "blurry"
}
/**
* TagEntity - Normalized tag storage
*
* EXPLICIT COLUMN MAPPINGS for KSP compatibility
*/
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey
@ColumnInfo(name = "tagId")
val tagId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "value")
val value: String,
@ColumnInfo(name = "createdAt")
val createdAt: Long
) {
companion object {
/**
* Create a generic user tag
*/
fun createUserTag(value: String): TagEntity {
return TagEntity(
tagId = UUID.randomUUID().toString(),
type = TagType.GENERIC,
value = value.trim().lowercase(),
createdAt = System.currentTimeMillis()
)
}
/**
* Create a system tag (auto-generated)
*/
fun createSystemTag(value: String): TagEntity {
return TagEntity(
tagId = UUID.randomUUID().toString(),
type = TagType.SYSTEM,
value = value.trim().lowercase(),
createdAt = System.currentTimeMillis()
)
}
/**
* Create hidden tag (internal use)
*/
fun createHiddenTag(value: String): TagEntity {
return TagEntity(
tagId = UUID.randomUUID().toString(),
type = TagType.HIDDEN,
value = value.trim().lowercase(),
createdAt = System.currentTimeMillis()
)
}
}
/**
* Check if this is a user-created tag
*/
fun isUserTag(): Boolean = type == TagType.GENERIC
/**
* Check if this is a system tag
*/
fun isSystemTag(): Boolean = type == TagType.SYSTEM
/**
* Check if this is a hidden tag
*/
fun isHiddenTag(): Boolean = type == TagType.HIDDEN
/**
* Get display value (capitalized for UI)
*/
fun getDisplayValue(): String = value.replaceFirstChar { it.uppercase() }
}
/**
* TagWithUsage - For queries that include usage count
*
* NOT AN ENTITY - just a POJO for query results
* Do NOT add this to @Database entities list!
*/
data class TagWithUsage(
@ColumnInfo(name = "tagId")
val tagId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "value")
val value: String,
@ColumnInfo(name = "createdAt")
val createdAt: Long,
@ColumnInfo(name = "usage_count")
val usageCount: Int
) {
/**
* Convert to TagEntity (without usage count)
*/
fun toTagEntity(): TagEntity {
return TagEntity(
tagId = tagId,
type = type,
value = value,
createdAt = createdAt
)
}
}

View File

@@ -0,0 +1,23 @@
package com.placeholder.sherpai2.data.local.model
import androidx.room.Embedded
import androidx.room.Relation
import com.placeholder.sherpai2.data.local.entity.*
data class ImageWithEverything(
@Embedded
val image: ImageEntity,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val tags: List<ImageTagEntity>,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val events: List<ImageEventEntity>
)

View File

@@ -0,0 +1,18 @@
package com.placeholder.sherpai2.data.local.model
import androidx.room.Embedded
import androidx.room.Relation
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
data class ImageWithTags(
@Embedded
val image: ImageEntity,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val tags: List<ImageTagEntity>
)

View File

@@ -0,0 +1,400 @@
package com.placeholder.sherpai2.data.repository
import android.content.Context
import android.graphics.Bitmap
import com.placeholder.sherpai2.data.local.dao.FaceModelDao
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao
import com.placeholder.sherpai2.data.local.entity.*
import com.placeholder.sherpai2.ml.FaceNetModel
import com.placeholder.sherpai2.ui.trainingprep.TrainingSanityChecker
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* FaceRecognitionRepository - Complete face recognition system
*
* USES STRING IDs TO MATCH YOUR SCHEMA:
* - PersonEntity.id: String (UUID)
* - ImageEntity.imageId: String
* - FaceModelEntity.id: String (UUID)
* - PhotoFaceTagEntity.id: String (UUID)
*/
@Singleton
class FaceRecognitionRepository @Inject constructor(
private val context: Context,
private val personDao: PersonDao,
private val imageDao: ImageDao,
private val faceModelDao: FaceModelDao,
private val photoFaceTagDao: PhotoFaceTagDao
) {
private val faceNetModel by lazy { FaceNetModel(context) }
// ======================
// TRAINING OPERATIONS
// ======================
/**
* Create a new person with face model in one operation.
*
* @return PersonId (String UUID)
*/
suspend fun createPersonWithFaceModel(
personName: String,
validImages: List<TrainingSanityChecker.ValidTrainingImage>,
onProgress: (Int, Int) -> Unit = { _, _ -> }
): String = withContext(Dispatchers.IO) {
// Create PersonEntity with UUID
val person = PersonEntity.create(name = personName)
personDao.insert(person)
// Train face model
trainPerson(
personId = person.id,
validImages = validImages,
onProgress = onProgress
)
person.id
}
/**
* Train a face recognition model for an existing person.
*
* @param personId String UUID
* @return Face model ID (String UUID)
*/
suspend fun trainPerson(
personId: String,
validImages: List<TrainingSanityChecker.ValidTrainingImage>,
onProgress: (Int, Int) -> Unit = { _, _ -> }
): String = withContext(Dispatchers.Default) {
val person = personDao.getPersonById(personId)
?: throw IllegalArgumentException("Person with ID $personId not found")
val embeddings = faceNetModel.generateEmbeddingsBatch(
faceBitmaps = validImages.map { it.croppedFaceBitmap },
onProgress = onProgress
)
val personEmbedding = faceNetModel.createPersonModel(embeddings)
val confidences = embeddings.map { embedding ->
faceNetModel.calculateSimilarity(personEmbedding, embedding)
}
val avgConfidence = confidences.average().toFloat()
val faceModel = FaceModelEntity.create(
personId = personId,
embeddingArray = personEmbedding,
trainingImageCount = validImages.size,
averageConfidence = avgConfidence
)
faceModelDao.insertFaceModel(faceModel)
faceModel.id
}
/**
* Retrain face model with additional images.
*/
suspend fun retrainFaceModel(
faceModelId: String,
newFaceImages: List<Bitmap>
) = withContext(Dispatchers.Default) {
val faceModel = faceModelDao.getFaceModelById(faceModelId)
?: throw IllegalArgumentException("Face model $faceModelId not found")
val existingEmbedding = faceModel.getEmbeddingArray()
val newEmbeddings = faceNetModel.generateEmbeddingsBatch(newFaceImages)
val allEmbeddings = listOf(existingEmbedding) + newEmbeddings
val updatedEmbedding = faceNetModel.createPersonModel(allEmbeddings)
val confidences = allEmbeddings.map { embedding ->
faceNetModel.calculateSimilarity(updatedEmbedding, embedding)
}
val avgConfidence = confidences.average().toFloat()
faceModelDao.updateFaceModel(
FaceModelEntity.create(
personId = faceModel.personId,
embeddingArray = updatedEmbedding,
trainingImageCount = faceModel.trainingImageCount + newFaceImages.size,
averageConfidence = avgConfidence
).copy(
id = faceModelId,
createdAt = faceModel.createdAt,
updatedAt = System.currentTimeMillis()
)
)
}
// ======================
// SCANNING / RECOGNITION
// ======================
/**
* Scan an image for faces and tag recognized persons.
*
* @param imageId String (from ImageEntity.imageId)
*/
suspend fun scanImage(
imageId: String,
detectedFaces: List<DetectedFace>,
threshold: Float = FaceNetModel.SIMILARITY_THRESHOLD_HIGH
): List<PhotoFaceTagEntity> = withContext(Dispatchers.Default) {
val faceModels = faceModelDao.getAllActiveFaceModels()
if (faceModels.isEmpty()) {
return@withContext emptyList()
}
val tags = mutableListOf<PhotoFaceTagEntity>()
for (detectedFace in detectedFaces) {
val faceEmbedding = faceNetModel.generateEmbedding(detectedFace.croppedBitmap)
var bestMatch: Pair<String, Float>? = null
var highestSimilarity = threshold
for (faceModel in faceModels) {
val modelEmbedding = faceModel.getEmbeddingArray()
val similarity = faceNetModel.calculateSimilarity(faceEmbedding, modelEmbedding)
if (similarity > highestSimilarity) {
highestSimilarity = similarity
bestMatch = Pair(faceModel.id, similarity)
}
}
if (bestMatch != null) {
val (faceModelId, confidence) = bestMatch
val tag = PhotoFaceTagEntity.create(
imageId = imageId,
faceModelId = faceModelId,
boundingBox = detectedFace.boundingBox,
confidence = confidence,
faceEmbedding = faceEmbedding
)
tags.add(tag)
faceModelDao.updateLastUsed(faceModelId, System.currentTimeMillis())
}
}
if (tags.isNotEmpty()) {
photoFaceTagDao.insertTags(tags)
}
tags
}
/**
* Recognize a single face bitmap (without saving).
*/
suspend fun recognizeFace(
faceBitmap: Bitmap,
threshold: Float = FaceNetModel.SIMILARITY_THRESHOLD_HIGH
): Pair<String, Float>? = withContext(Dispatchers.Default) {
val faceEmbedding = faceNetModel.generateEmbedding(faceBitmap)
val faceModels = faceModelDao.getAllActiveFaceModels()
val modelEmbeddings = faceModels.map { it.id to it.getEmbeddingArray() }
faceNetModel.findBestMatch(faceEmbedding, modelEmbeddings, threshold)
}
// ======================
// SEARCH / QUERY
// ======================
/**
* Get all images containing a specific person.
*
* @param personId String UUID
*/
suspend fun getImagesForPerson(personId: String): List<ImageEntity> = withContext(Dispatchers.IO) {
val faceModel = faceModelDao.getFaceModelByPersonId(personId)
?: return@withContext emptyList()
val imageIds = photoFaceTagDao.getImageIdsForFaceModel(faceModel.id)
imageDao.getImagesByIds(imageIds)
}
/**
* Get images for person as Flow (reactive).
*/
fun getImagesForPersonFlow(personId: String): Flow<List<ImageEntity>> {
return photoFaceTagDao.getImageIdsForFaceModelFlow(personId)
.map { imageIds ->
imageDao.getImagesByIds(imageIds)
}
}
/**
* Get all persons with face models.
*/
suspend fun getPersonsWithFaceModels(): List<PersonEntity> = withContext(Dispatchers.IO) {
val faceModels = faceModelDao.getAllActiveFaceModels()
val personIds = faceModels.map { it.personId }
personDao.getPersonsByIds(personIds)
}
/**
* Get face detection stats for a person.
*/
suspend fun getPersonFaceStats(personId: String): PersonFaceStats? = withContext(Dispatchers.IO) {
val person = personDao.getPersonById(personId) ?: return@withContext null
val faceModel = faceModelDao.getFaceModelByPersonId(personId) ?: return@withContext null
val imageIds = photoFaceTagDao.getImageIdsForFaceModel(faceModel.id)
val allTags = photoFaceTagDao.getAllTagsForFaceModel(faceModel.id)
val avgConfidence = if (allTags.isNotEmpty()) {
allTags.map { it.confidence }.average().toFloat()
} else {
0f
}
val lastDetected = allTags.maxOfOrNull { it.detectedAt }
PersonFaceStats(
personId = person.id,
personName = person.name,
faceModelId = faceModel.id,
trainingImageCount = faceModel.trainingImageCount,
taggedPhotoCount = imageIds.size,
averageConfidence = avgConfidence,
lastDetectedAt = lastDetected
)
}
/**
* Get face tags for an image.
*/
suspend fun getFaceTagsForImage(imageId: String): List<PhotoFaceTagEntity> {
return photoFaceTagDao.getTagsForImage(imageId)
}
/**
* Get person from a face tag.
*/
suspend fun getPersonForFaceTag(tag: PhotoFaceTagEntity): PersonEntity? = withContext(Dispatchers.IO) {
val faceModel = faceModelDao.getFaceModelById(tag.faceModelId) ?: return@withContext null
personDao.getPersonById(faceModel.personId)
}
/**
* Get face tags with person info for an image.
*/
suspend fun getFaceTagsWithPersons(imageId: String): List<Pair<PhotoFaceTagEntity, PersonEntity>> = withContext(Dispatchers.IO) {
val tags = photoFaceTagDao.getTagsForImage(imageId)
tags.mapNotNull { tag ->
val person = getPersonForFaceTag(tag)
if (person != null) tag to person else null
}
}
// ======================
// VERIFICATION / QUALITY
// ======================
suspend fun verifyFaceTag(tagId: String) {
photoFaceTagDao.markTagAsVerified(
tagId = tagId,
timestamp = System.currentTimeMillis()
)
}
suspend fun getUnverifiedTags(): List<PhotoFaceTagEntity> {
return photoFaceTagDao.getUnverifiedTags()
}
suspend fun getLowConfidenceTags(threshold: Float = 0.7f): List<PhotoFaceTagEntity> {
return photoFaceTagDao.getLowConfidenceTags(threshold)
}
// ======================
// MANAGEMENT
// ======================
suspend fun deleteFaceModel(faceModelId: String) = withContext(Dispatchers.IO) {
photoFaceTagDao.deleteTagsForFaceModel(faceModelId)
faceModelDao.deleteFaceModelById(faceModelId)
}
// Add this method to FaceRecognitionRepository_StringIds.kt
// Replace the existing createPersonWithFaceModel method with this version:
/**
* Create a new person with face model in one operation.
* Now supports full PersonEntity with optional fields.
*
* @param person PersonEntity with name, DOB, relationship, etc.
* @return PersonId (String UUID)
*/
suspend fun createPersonWithFaceModel(
person: PersonEntity,
validImages: List<TrainingSanityChecker.ValidTrainingImage>,
onProgress: (Int, Int) -> Unit = { _, _ -> }
): String = withContext(Dispatchers.IO) {
// Insert person with all fields
personDao.insert(person)
// Train face model
trainPerson(
personId = person.id,
validImages = validImages,
onProgress = onProgress
)
person.id
}
/**
* Get face model by ID
*/
suspend fun getFaceModelById(faceModelId: String): FaceModelEntity? = withContext(Dispatchers.IO) {
faceModelDao.getFaceModelById(faceModelId)
}
suspend fun deleteTagsForImage(imageId: String) {
photoFaceTagDao.deleteTagsForImage(imageId)
}
fun cleanup() {
faceNetModel.close()
}
}
data class DetectedFace(
val croppedBitmap: Bitmap,
val boundingBox: android.graphics.Rect
)
data class PersonFaceStats(
val personId: String,
val personName: String,
val faceModelId: String,
val trainingImageCount: Int,
val taggedPhotoCount: Int,
val averageConfidence: Float,
val lastDetectedAt: Long?
)

View File

@@ -0,0 +1,380 @@
package com.placeholder.sherpai2.data.service
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.util.DiagnosticLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Calendar
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* AutoTaggingService - Intelligent auto-tagging system
*
* Capabilities:
* - Face-based tags (group_photo, selfie, couple)
* - Scene tags (portrait, landscape, square orientation)
* - Time tags (morning, afternoon, evening, night)
* - Quality tags (high_res, low_res)
* - Relationship tags (family, friend, colleague from PersonEntity)
* - Birthday tags (from PersonEntity DOB)
* - Indoor/Outdoor estimation (basic heuristic)
*/
@Singleton
class AutoTaggingService @Inject constructor(
@ApplicationContext private val context: Context,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val photoFaceTagDao: PhotoFaceTagDao,
private val personDao: PersonDao
) {
// ======================
// MAIN AUTO-TAGGING
// ======================
/**
* Auto-tag an image with all applicable system tags
*
* @return Number of tags applied
*/
suspend fun autoTagImage(
imageEntity: ImageEntity,
bitmap: Bitmap,
detectedFaces: List<DetectedFace>
): Int = withContext(Dispatchers.Default) {
val tagsToApply = mutableListOf<String>()
// Face-count based tags
when (detectedFaces.size) {
0 -> { /* No face tags */ }
1 -> {
if (isSelfie(detectedFaces[0], bitmap)) {
tagsToApply.add("selfie")
} else {
tagsToApply.add("single_person")
}
}
2 -> tagsToApply.add("couple")
in 3..5 -> tagsToApply.add("group_photo")
in 6..10 -> {
tagsToApply.add("group_photo")
tagsToApply.add("large_group")
}
else -> {
tagsToApply.add("group_photo")
tagsToApply.add("large_group")
tagsToApply.add("crowd")
}
}
// Orientation tags
val aspectRatio = bitmap.width.toFloat() / bitmap.height.toFloat()
when {
aspectRatio > 1.3f -> tagsToApply.add("landscape")
aspectRatio < 0.77f -> tagsToApply.add("portrait")
else -> tagsToApply.add("square")
}
// Resolution tags
val megapixels = (bitmap.width * bitmap.height) / 1_000_000f
when {
megapixels > 2.0f -> tagsToApply.add("high_res")
megapixels < 0.5f -> tagsToApply.add("low_res")
}
// Time-based tags
val hourOfDay = getHourFromTimestamp(imageEntity.capturedAt)
tagsToApply.add(when (hourOfDay) {
in 5..10 -> "morning"
in 11..16 -> "afternoon"
in 17..20 -> "evening"
else -> "night"
})
// Indoor/Outdoor estimation (only if image is large enough)
if (bitmap.width >= 200 && bitmap.height >= 200) {
val isIndoor = estimateIndoorOutdoor(bitmap)
tagsToApply.add(if (isIndoor) "indoor" else "outdoor")
}
// Apply all tags
var tagsApplied = 0
tagsToApply.forEach { tagName ->
if (applySystemTag(imageEntity.imageId, tagName)) {
tagsApplied++
}
}
DiagnosticLogger.d("AutoTag: Applied $tagsApplied tags to image ${imageEntity.imageId}")
tagsApplied
}
// ======================
// RELATIONSHIP TAGS
// ======================
/**
* Tag all images with a person using their relationship tag
*
* @param personId Person to tag images for
* @return Number of tags applied
*/
suspend fun autoTagRelationshipForPerson(personId: String): Int = withContext(Dispatchers.IO) {
val person = personDao.getPersonById(personId) ?: return@withContext 0
val relationship = person.relationship?.lowercase() ?: return@withContext 0
// Get face model for this person
val faceModels = photoFaceTagDao.getAllTagsForFaceModel(personId)
if (faceModels.isEmpty()) return@withContext 0
val imageIds = faceModels.map { it.imageId }.distinct()
var tagsApplied = 0
imageIds.forEach { imageId ->
if (applySystemTag(imageId, relationship)) {
tagsApplied++
}
}
DiagnosticLogger.i("AutoTag: Applied '$relationship' tag to $tagsApplied images for ${person.name}")
tagsApplied
}
/**
* Tag relationships for ALL persons in database
*/
suspend fun autoTagAllRelationships(): Int = withContext(Dispatchers.IO) {
val persons = personDao.getAllPersons()
var totalTags = 0
persons.forEach { person ->
totalTags += autoTagRelationshipForPerson(person.id)
}
DiagnosticLogger.i("AutoTag: Applied $totalTags relationship tags across ${persons.size} persons")
totalTags
}
// ======================
// BIRTHDAY TAGS
// ======================
/**
* Tag images near a person's birthday
*
* @param personId Person whose birthday to check
* @param daysRange Days before/after birthday to consider (default: 3)
* @return Number of tags applied
*/
suspend fun autoTagBirthdaysForPerson(
personId: String,
daysRange: Int = 3
): Int = withContext(Dispatchers.IO) {
val person = personDao.getPersonById(personId) ?: return@withContext 0
val dateOfBirth = person.dateOfBirth ?: return@withContext 0
// Get all face tags for this person
val faceTags = photoFaceTagDao.getAllTagsForFaceModel(personId)
if (faceTags.isEmpty()) return@withContext 0
var tagsApplied = 0
faceTags.forEach { faceTag ->
// Get the image to check its timestamp
val imageId = faceTag.imageId
// Check if image was captured near birthday
if (isNearBirthday(faceTag.detectedAt, dateOfBirth, daysRange)) {
if (applySystemTag(imageId, "birthday")) {
tagsApplied++
}
}
}
DiagnosticLogger.i("AutoTag: Applied 'birthday' tag to $tagsApplied images for ${person.name}")
tagsApplied
}
/**
* Tag birthdays for ALL persons with DOB
*/
suspend fun autoTagAllBirthdays(daysRange: Int = 3): Int = withContext(Dispatchers.IO) {
val persons = personDao.getAllPersons()
var totalTags = 0
persons.forEach { person ->
if (person.dateOfBirth != null) {
totalTags += autoTagBirthdaysForPerson(person.id, daysRange)
}
}
DiagnosticLogger.i("AutoTag: Applied $totalTags birthday tags")
totalTags
}
// ======================
// HELPER METHODS
// ======================
/**
* Check if an image is a selfie based on face size
*/
private fun isSelfie(face: DetectedFace, bitmap: Bitmap): Boolean {
val boundingBox = face.boundingBox
val faceArea = boundingBox.width() * boundingBox.height()
val imageArea = bitmap.width * bitmap.height
val faceRatio = faceArea.toFloat() / imageArea.toFloat()
// Selfie = face takes up significant portion (>15% of image)
return faceRatio > 0.15f
}
/**
* Get hour of day from timestamp (0-23)
*/
private fun getHourFromTimestamp(timestamp: Long): Int {
return Calendar.getInstance().apply {
timeInMillis = timestamp
}.get(Calendar.HOUR_OF_DAY)
}
/**
* Check if a timestamp is near a birthday
*/
private fun isNearBirthday(
capturedTimestamp: Long,
dobTimestamp: Long,
daysRange: Int
): Boolean {
val capturedCal = Calendar.getInstance().apply { timeInMillis = capturedTimestamp }
val dobCal = Calendar.getInstance().apply { timeInMillis = dobTimestamp }
val capturedMonth = capturedCal.get(Calendar.MONTH)
val capturedDay = capturedCal.get(Calendar.DAY_OF_MONTH)
val dobMonth = dobCal.get(Calendar.MONTH)
val dobDay = dobCal.get(Calendar.DAY_OF_MONTH)
if (capturedMonth == dobMonth) {
return abs(capturedDay - dobDay) <= daysRange
}
// Handle edge case: birthday near end/start of month
// e.g., DOB = Jan 2, captured = Dec 31 (within 3 days)
if (abs(capturedMonth - dobMonth) == 1 || abs(capturedMonth - dobMonth) == 11) {
val daysInCapturedMonth = capturedCal.getActualMaximum(Calendar.DAY_OF_MONTH)
val daysInDobMonth = dobCal.getActualMaximum(Calendar.DAY_OF_MONTH)
if (capturedMonth < dobMonth || (capturedMonth == 11 && dobMonth == 0)) {
// Captured before DOB month
val dayDiff = (daysInCapturedMonth - capturedDay) + dobDay
return dayDiff <= daysRange
} else {
// Captured after DOB month
val dayDiff = (daysInDobMonth - dobDay) + capturedDay
return dayDiff <= daysRange
}
}
return false
}
/**
* Basic indoor/outdoor estimation using brightness and saturation
*
* Heuristic:
* - Outdoor: Higher brightness (>120), Higher saturation (>0.25)
* - Indoor: Lower brightness, Lower saturation
*/
private fun estimateIndoorOutdoor(bitmap: Bitmap): Boolean {
// Sample pixels for analysis (don't process entire image)
val sampleSize = 100
val sampledPixels = mutableListOf<Int>()
val stepX = bitmap.width / sampleSize.coerceAtMost(bitmap.width)
val stepY = bitmap.height / sampleSize.coerceAtMost(bitmap.height)
for (x in 0 until sampleSize.coerceAtMost(bitmap.width)) {
for (y in 0 until sampleSize.coerceAtMost(bitmap.height)) {
val px = (x * stepX).coerceIn(0, bitmap.width - 1)
val py = (y * stepY).coerceIn(0, bitmap.height - 1)
sampledPixels.add(bitmap.getPixel(px, py))
}
}
if (sampledPixels.isEmpty()) return true // Default to indoor if sampling fails
// Calculate average brightness
val avgBrightness = sampledPixels.map { pixel ->
val r = Color.red(pixel)
val g = Color.green(pixel)
val b = Color.blue(pixel)
(r + g + b) / 3.0f
}.average()
// Calculate color saturation
val avgSaturation = sampledPixels.map { pixel ->
val hsv = FloatArray(3)
Color.colorToHSV(pixel, hsv)
hsv[1] // Saturation
}.average()
// Heuristic: Indoor if low brightness OR low saturation
return avgBrightness < 120 || avgSaturation < 0.25
}
/**
* Apply a system tag to an image (helper to avoid duplicates)
*
* @return true if tag was applied, false if already exists
*/
private suspend fun applySystemTag(imageId: String, tagName: String): Boolean {
return withContext(Dispatchers.IO) {
try {
// Get or create tag
val tag = getOrCreateSystemTag(tagName)
// Create image-tag link
val imageTag = ImageTagEntity(
imageId = imageId,
tagId = tag.tagId,
source = "AUTO",
confidence = 1.0f,
visibility = "PUBLIC",
createdAt = System.currentTimeMillis()
)
imageTagDao.upsert(imageTag)
true
} catch (e: Exception) {
DiagnosticLogger.e("Failed to apply tag '$tagName' to image $imageId", e)
false
}
}
}
/**
* Get existing system tag or create new one
*/
private suspend fun getOrCreateSystemTag(tagName: String): TagEntity {
return withContext(Dispatchers.IO) {
tagDao.getByValue(tagName) ?: run {
val newTag = TagEntity.createSystemTag(tagName)
tagDao.insert(newTag)
newTag
}
}
}
}

View File

@@ -0,0 +1,79 @@
package com.placeholder.sherpai2.di
import android.content.Context
import androidx.room.Room
import com.placeholder.sherpai2.data.local.AppDatabase
import com.placeholder.sherpai2.data.local.dao.*
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* DatabaseModule - Provides database and ALL DAOs
*
* DEVELOPMENT CONFIGURATION:
* - fallbackToDestructiveMigration enabled
* - No migrations required
*/
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
// ===== DATABASE =====
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase =
Room.databaseBuilder(
context,
AppDatabase::class.java,
"sherpai.db"
)
.fallbackToDestructiveMigration()
.build()
// ===== CORE DAOs =====
@Provides
fun provideImageDao(db: AppDatabase): ImageDao =
db.imageDao()
@Provides
fun provideTagDao(db: AppDatabase): TagDao =
db.tagDao()
@Provides
fun provideEventDao(db: AppDatabase): EventDao =
db.eventDao()
@Provides
fun provideImageEventDao(db: AppDatabase): ImageEventDao =
db.imageEventDao()
@Provides
fun provideImageAggregateDao(db: AppDatabase): ImageAggregateDao =
db.imageAggregateDao()
@Provides
fun provideImageTagDao(db: AppDatabase): ImageTagDao =
db.imageTagDao()
// ===== FACE RECOGNITION DAOs =====
@Provides
fun providePersonDao(db: AppDatabase): PersonDao =
db.personDao()
@Provides
fun provideFaceModelDao(db: AppDatabase): FaceModelDao =
db.faceModelDao()
@Provides
fun providePhotoFaceTagDao(db: AppDatabase): PhotoFaceTagDao =
db.photoFaceTagDao()
}

View File

@@ -0,0 +1,34 @@
package com.placeholder.sherpai2.di
import android.content.Context
import com.placeholder.sherpai2.ml.FaceNetModel
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* MLModule - Provides ML-related dependencies
*
* This module provides FaceNetModel for dependency injection
*/
@Module
@InstallIn(SingletonComponent::class)
object MLModule {
/**
* Provide FaceNetModel singleton
*
* FaceNetModel loads the MobileFaceNet TFLite model and manages
* face embedding generation for recognition.
*/
@Provides
@Singleton
fun provideFaceNetModel(
@ApplicationContext context: Context
): FaceNetModel {
return FaceNetModel(context)
}
}

View File

@@ -0,0 +1,90 @@
package com.placeholder.sherpai2.di
import android.content.Context
import com.placeholder.sherpai2.data.local.dao.FaceModelDao
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.data.repository.TaggingRepositoryImpl
import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.domain.repository.ImageRepositoryImpl
import com.placeholder.sherpai2.domain.repository.TaggingRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* RepositoryModule - Provides repository implementations
*
* UPDATED TO INCLUDE:
* - FaceRecognitionRepository for face recognition operations
*/
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
// ===== EXISTING REPOSITORY BINDINGS =====
@Binds
@Singleton
abstract fun bindImageRepository(
impl: ImageRepositoryImpl
): ImageRepository
@Binds
@Singleton
abstract fun bindTaggingRepository(
impl: TaggingRepositoryImpl
): TaggingRepository
// ===== COMPANION OBJECT FOR PROVIDES =====
companion object {
/**
* Provide FaceRecognitionRepository
*
* Uses @Provides instead of @Binds because it needs Context parameter
* and multiple DAO dependencies
*
* INJECTED DEPENDENCIES:
* - Context: For FaceNetModel initialization
* - PersonDao: Access existing persons
* - ImageDao: Access existing images
* - FaceModelDao: Manage face models
* - PhotoFaceTagDao: Manage photo tags
*
* USAGE IN VIEWMODEL:
* ```
* @HiltViewModel
* class MyViewModel @Inject constructor(
* private val faceRecognitionRepository: FaceRecognitionRepository
* ) : ViewModel() {
* // Use repository methods
* }
* ```
*/
@Provides
@Singleton
fun provideFaceRecognitionRepository(
@ApplicationContext context: Context,
personDao: PersonDao,
imageDao: ImageDao,
faceModelDao: FaceModelDao,
photoFaceTagDao: PhotoFaceTagDao
): FaceRecognitionRepository {
return FaceRecognitionRepository(
context = context,
personDao = personDao,
imageDao = imageDao,
faceModelDao = faceModelDao,
photoFaceTagDao = photoFaceTagDao
)
}
}
}

View File

@@ -0,0 +1,33 @@
package com.placeholder.sherpai2.domain.repository
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import kotlinx.coroutines.flow.Flow
/**
* Canonical access point for images.
*
* ViewModels must NEVER talk directly to DAOs.
*/
interface ImageRepository {
/**
* Observe a fully-hydrated image graph.
*
* Used by detail screens.
*/
fun observeImage(imageId: String): Flow<ImageWithEverything>
/**
* Ingest images discovered on device.
*
* This function:
* - deduplicates
* - assigns events automatically
*/
suspend fun ingestImages()
fun getAllImages(): Flow<List<ImageWithEverything>>
fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>>
fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>>
}

View File

@@ -0,0 +1,147 @@
package com.placeholder.sherpai2.domain.repository
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import com.placeholder.sherpai2.data.local.dao.EventDao
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageEventDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.security.MessageDigest
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ImageRepositoryImpl @Inject constructor(
private val imageDao: ImageDao,
private val eventDao: EventDao,
private val imageEventDao: ImageEventDao,
private val aggregateDao: ImageAggregateDao,
@ApplicationContext private val context: Context
) : ImageRepository {
override fun observeImage(imageId: String): Flow<ImageWithEverything> {
return aggregateDao.observeImageWithEverything(imageId)
}
/**
* Ingest all images from MediaStore.
* Uses _ID and DATE_ADDED to ensure no image is skipped, even if DATE_TAKEN is identical.
*/
override suspend fun ingestImages(): Unit = withContext(Dispatchers.IO) {
try {
val imageList = mutableListOf<ImageEntity>()
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_TAKEN,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.WIDTH,
MediaStore.Images.Media.HEIGHT
)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC"
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val dateTakenCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val dateAddedCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val widthCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.WIDTH)
val heightCol = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.HEIGHT)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val displayName = cursor.getString(nameCol)
val dateTaken = cursor.getLong(dateTakenCol)
val dateAdded = cursor.getLong(dateAddedCol)
val width = cursor.getInt(widthCol)
val height = cursor.getInt(heightCol)
val contentUri: Uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
)
val sha256 = computeSHA256(contentUri)
if (sha256 == null) {
Log.w("ImageRepository", "Skipped image: $displayName (cannot read bytes)")
continue
}
val imageEntity = ImageEntity(
imageId = UUID.randomUUID().toString(),
imageUri = contentUri.toString(),
sha256 = sha256,
capturedAt = if (dateTaken > 0) dateTaken else dateAdded * 1000,
ingestedAt = System.currentTimeMillis(),
width = width,
height = height,
source = "CAMERA" // or SCREENSHOT / IMPORTED
)
imageList += imageEntity
Log.i("ImageRepository", "Processing image: $displayName, SHA256: $sha256")
}
}
if (imageList.isNotEmpty()) {
imageDao.insertImages(imageList)
Log.i("ImageRepository", "Ingested ${imageList.size} images")
} else {
Log.i("ImageRepository", "No images found on device")
}
} catch (e: Exception) {
Log.e("ImageRepository", "Error ingesting images", e)
}
}
/**
* Compute SHA256 from a MediaStore Uri safely.
*/
private fun computeSHA256(uri: Uri): String? {
return try {
val digest = MessageDigest.getInstance("SHA-256")
context.contentResolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(8192)
var read: Int
while (input.read(buffer).also { read = it } > 0) {
digest.update(buffer, 0, read)
}
} ?: return null
digest.digest().joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
Log.e("ImageRepository", "Failed SHA256 for $uri", e)
null
}
}
override fun getAllImages(): Flow<List<ImageWithEverything>> {
return aggregateDao.observeAllImagesWithEverything()
}
override fun findImagesByTag(tag: String): Flow<List<ImageWithEverything>> {
return aggregateDao.observeImagesWithTag(tag)
}
override fun getRecentImages(limit: Int): Flow<List<ImageWithEverything>> {
return imageDao.getRecentImages(limit)
}
}

View File

@@ -0,0 +1,30 @@
package com.placeholder.sherpai2.domain.repository
import com.placeholder.sherpai2.data.local.entity.TagEntity
import kotlinx.coroutines.flow.Flow
/**
* Handles all tagging operations.
*
* This repository is the ONLY place where:
* - tags are attached
* - visibility rules are applied
*/
interface TaggingRepository {
suspend fun addTagToImage(
imageId: String,
tagValue: String,
source: String,
confidence: Float
)
suspend fun hideTagForImage(
imageId: String,
tagValue: String
)
fun getTagsForImage(imageId: String): Flow<List<TagEntity>>
suspend fun removeTagFromImage(imageId: String, tagId: String)
}

View File

@@ -0,0 +1,97 @@
package com.placeholder.sherpai2.data.repository
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.domain.repository.TaggingRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
/**
*
*
* Critical design decisions here
*
* Tag normalization happens once
*
* Visibility rules live here
*
* ML and manual tagging share the same path
*/
@Singleton
class TaggingRepositoryImpl @Inject constructor(
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao
) : TaggingRepository {
override suspend fun addTagToImage(
imageId: String,
tagValue: String,
source: String,
confidence: Float
) {
// Step 1: normalize tag
val normalized = tagValue.trim().lowercase()
// Step 2: ensure tag exists
val tag = tagDao.getByValue(normalized)
?: TagEntity(
tagId = "tag_$normalized",
type = "GENERIC",
value = normalized,
createdAt = System.currentTimeMillis()
).also { tagDao.insert(it) }
// Step 3: attach tag to image
imageTagDao.upsert(
ImageTagEntity(
imageId = imageId,
tagId = tag.tagId,
source = source,
confidence = confidence,
visibility = "PUBLIC",
createdAt = System.currentTimeMillis()
)
)
}
override suspend fun hideTagForImage(
imageId: String,
tagValue: String
) {
val tag = tagDao.getByValue(tagValue) ?: return
imageTagDao.upsert(
ImageTagEntity(
imageId = imageId,
tagId = tag.tagId,
source = "MANUAL",
confidence = 1.0f,
visibility = "HIDDEN",
createdAt = System.currentTimeMillis()
)
)
}
override fun getTagsForImage(imageId: String): Flow<List<TagEntity>> {
// Join imageTagDao -> tagDao to get all PUBLIC tags for this image
return imageTagDao.getTagsForImage(imageId)
}
override suspend fun removeTagFromImage(imageId: String, tagId: String) {
// Mark the tag as hidden instead of deleting, keeping the visibility logic
imageTagDao.upsert(
ImageTagEntity(
imageId = imageId,
tagId = tagId,
source = "MANUAL",
confidence = 1.0f,
visibility = "HIDDEN",
createdAt = System.currentTimeMillis()
)
)
}
}

View File

@@ -0,0 +1,204 @@
package com.placeholder.sherpai2.ml
import android.content.Context
import android.graphics.Bitmap
import org.tensorflow.lite.Interpreter
import java.io.FileInputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.MappedByteBuffer
import java.nio.channels.FileChannel
import kotlin.math.sqrt
/**
* FaceNetModel - MobileFaceNet wrapper for face recognition
*
* CLEAN IMPLEMENTATION:
* - All IDs are Strings (matching your schema)
* - Generates 192-dimensional embeddings
* - Cosine similarity for matching
*/
class FaceNetModel(private val context: Context) {
companion object {
private const val MODEL_FILE = "mobilefacenet.tflite"
private const val INPUT_SIZE = 112
private const val EMBEDDING_SIZE = 192
const val SIMILARITY_THRESHOLD_HIGH = 0.7f
const val SIMILARITY_THRESHOLD_MEDIUM = 0.6f
const val SIMILARITY_THRESHOLD_LOW = 0.5f
}
private var interpreter: Interpreter? = null
init {
try {
val model = loadModelFile()
interpreter = Interpreter(model)
} catch (e: Exception) {
throw RuntimeException("Failed to load FaceNet model", e)
}
}
/**
* Load TFLite model from assets
*/
private fun loadModelFile(): MappedByteBuffer {
val fileDescriptor = context.assets.openFd(MODEL_FILE)
val inputStream = FileInputStream(fileDescriptor.fileDescriptor)
val fileChannel = inputStream.channel
val startOffset = fileDescriptor.startOffset
val declaredLength = fileDescriptor.declaredLength
return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength)
}
/**
* Generate embedding for a single face
*
* @param faceBitmap Cropped face image (will be resized to 112x112)
* @return 192-dimensional embedding
*/
fun generateEmbedding(faceBitmap: Bitmap): FloatArray {
val resized = Bitmap.createScaledBitmap(faceBitmap, INPUT_SIZE, INPUT_SIZE, true)
val inputBuffer = preprocessImage(resized)
val output = Array(1) { FloatArray(EMBEDDING_SIZE) }
interpreter?.run(inputBuffer, output)
return normalizeEmbedding(output[0])
}
/**
* Generate embeddings for multiple faces (batch processing)
*/
fun generateEmbeddingsBatch(
faceBitmaps: List<Bitmap>,
onProgress: (Int, Int) -> Unit = { _, _ -> }
): List<FloatArray> {
return faceBitmaps.mapIndexed { index, bitmap ->
onProgress(index + 1, faceBitmaps.size)
generateEmbedding(bitmap)
}
}
/**
* Create person model by averaging multiple embeddings
*/
fun createPersonModel(embeddings: List<FloatArray>): FloatArray {
require(embeddings.isNotEmpty()) { "Need at least one embedding" }
val averaged = FloatArray(EMBEDDING_SIZE) { 0f }
embeddings.forEach { embedding ->
for (i in embedding.indices) {
averaged[i] += embedding[i]
}
}
val count = embeddings.size.toFloat()
for (i in averaged.indices) {
averaged[i] /= count
}
return normalizeEmbedding(averaged)
}
/**
* Calculate cosine similarity between two embeddings
* Returns value between -1.0 and 1.0 (higher = more similar)
*/
fun calculateSimilarity(embedding1: FloatArray, embedding2: FloatArray): Float {
require(embedding1.size == EMBEDDING_SIZE && embedding2.size == EMBEDDING_SIZE) {
"Invalid embedding size"
}
var dotProduct = 0f
var norm1 = 0f
var norm2 = 0f
for (i in embedding1.indices) {
dotProduct += embedding1[i] * embedding2[i]
norm1 += embedding1[i] * embedding1[i]
norm2 += embedding2[i] * embedding2[i]
}
return dotProduct / (sqrt(norm1) * sqrt(norm2))
}
/**
* Find best matching face model from a list
*
* @param faceEmbedding Embedding to match
* @param modelEmbeddings List of (modelId: String, embedding: FloatArray)
* @param threshold Minimum similarity threshold
* @return Pair of (modelId: String, confidence: Float) or null
*/
fun findBestMatch(
faceEmbedding: FloatArray,
modelEmbeddings: List<Pair<String, FloatArray>>,
threshold: Float = SIMILARITY_THRESHOLD_HIGH
): Pair<String, Float>? {
var bestMatch: Pair<String, Float>? = null
var highestSimilarity = threshold
for ((modelId, modelEmbedding) in modelEmbeddings) {
val similarity = calculateSimilarity(faceEmbedding, modelEmbedding)
if (similarity > highestSimilarity) {
highestSimilarity = similarity
bestMatch = Pair(modelId, similarity)
}
}
return bestMatch
}
/**
* Preprocess image for model input
*/
private fun preprocessImage(bitmap: Bitmap): ByteBuffer {
val buffer = ByteBuffer.allocateDirect(4 * INPUT_SIZE * INPUT_SIZE * 3)
buffer.order(ByteOrder.nativeOrder())
val pixels = IntArray(INPUT_SIZE * INPUT_SIZE)
bitmap.getPixels(pixels, 0, INPUT_SIZE, 0, 0, INPUT_SIZE, INPUT_SIZE)
for (pixel in pixels) {
val r = ((pixel shr 16) and 0xFF) / 255.0f
val g = ((pixel shr 8) and 0xFF) / 255.0f
val b = (pixel and 0xFF) / 255.0f
buffer.putFloat((r - 0.5f) / 0.5f)
buffer.putFloat((g - 0.5f) / 0.5f)
buffer.putFloat((b - 0.5f) / 0.5f)
}
return buffer
}
/**
* Normalize embedding to unit length
*/
private fun normalizeEmbedding(embedding: FloatArray): FloatArray {
var norm = 0f
for (value in embedding) {
norm += value * value
}
norm = sqrt(norm)
return if (norm > 0) {
FloatArray(embedding.size) { i -> embedding[i] / norm }
} else {
embedding
}
}
/**
* Clean up resources
*/
fun close() {
interpreter?.close()
interpreter = null
}
}

View File

@@ -0,0 +1,127 @@
package com.placeholder.sherpai2.ml
/**
* ThresholdStrategy - Smart threshold selection for face recognition
*
* Considers:
* - Training image count
* - Image quality
* - Detection context (group photo, selfie, etc.)
*/
object ThresholdStrategy {
/**
* Get optimal threshold for face recognition
*
* @param trainingCount Number of images used to train the model
* @param imageQuality Quality assessment of the image being scanned
* @param detectionContext Context of the detection (group, selfie, etc.)
* @return Similarity threshold (0.0 - 1.0)
*/
fun getOptimalThreshold(
trainingCount: Int,
imageQuality: ImageQuality = ImageQuality.UNKNOWN,
detectionContext: DetectionContext = DetectionContext.GENERAL
): Float {
// Base threshold from training count
val baseThreshold = when {
trainingCount >= 40 -> 0.68f // High confidence - strict
trainingCount >= 30 -> 0.62f // Good confidence - moderate-strict
trainingCount >= 20 -> 0.56f // Moderate confidence
trainingCount >= 15 -> 0.50f // Acceptable confidence - lenient
else -> 0.48f // Sparse training - very lenient
}
// Adjust based on image quality
val qualityAdjustment = when (imageQuality) {
ImageQuality.HIGH -> -0.02f // Can be stricter with good quality
ImageQuality.MEDIUM -> 0f // No change
ImageQuality.LOW -> +0.03f // Be more lenient with poor quality
ImageQuality.UNKNOWN -> 0f // No change
}
// Adjust based on detection context
val contextAdjustment = when (detectionContext) {
DetectionContext.GROUP_PHOTO -> +0.02f // More lenient in groups (faces smaller)
DetectionContext.SELFIE -> -0.03f // Stricter for close-ups (more detail)
DetectionContext.PROFILE -> +0.02f // More lenient for side profiles
DetectionContext.DISTANT -> +0.03f // More lenient for far away faces
DetectionContext.GENERAL -> 0f // No change
}
// Combine adjustments and clamp to safe range
return (baseThreshold + qualityAdjustment + contextAdjustment).coerceIn(0.40f, 0.75f)
}
/**
* Get threshold for liberal matching (e.g., during testing)
*/
fun getLiberalThreshold(trainingCount: Int): Float {
return when {
trainingCount >= 30 -> 0.52f
trainingCount >= 20 -> 0.48f
else -> 0.45f
}.coerceIn(0.40f, 0.65f)
}
/**
* Get threshold for conservative matching (minimize false positives)
*/
fun getConservativeThreshold(trainingCount: Int): Float {
return when {
trainingCount >= 40 -> 0.72f
trainingCount >= 30 -> 0.68f
trainingCount >= 20 -> 0.62f
else -> 0.58f
}.coerceIn(0.55f, 0.75f)
}
/**
* Estimate image quality from bitmap properties
*/
fun estimateImageQuality(width: Int, height: Int, fileSize: Long = 0): ImageQuality {
val megapixels = (width * height) / 1_000_000f
return when {
megapixels > 4.0f -> ImageQuality.HIGH
megapixels > 1.0f -> ImageQuality.MEDIUM
else -> ImageQuality.LOW
}
}
/**
* Estimate detection context from face count and face size
*/
fun estimateDetectionContext(
faceCount: Int,
faceAreaRatio: Float = 0f
): DetectionContext {
return when {
faceCount == 1 && faceAreaRatio > 0.15f -> DetectionContext.SELFIE
faceCount == 1 && faceAreaRatio < 0.05f -> DetectionContext.DISTANT
faceCount >= 3 -> DetectionContext.GROUP_PHOTO
else -> DetectionContext.GENERAL
}
}
}
/**
* Image quality assessment
*/
enum class ImageQuality {
HIGH, // > 4MP, good lighting
MEDIUM, // 1-4MP
LOW, // < 1MP, poor quality
UNKNOWN // Cannot determine
}
/**
* Detection context
*/
enum class DetectionContext {
GROUP_PHOTO, // Multiple faces (3+)
SELFIE, // Single face, close-up
PROFILE, // Side view
DISTANT, // Face is small in frame
GENERAL // Default
}

View File

@@ -1,36 +0,0 @@
// In navigation/AppDestinations.kt
package com.placeholder.sherpai2.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Defines all navigation destinations (screens) for the application.
*/
sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) {
// Core Functional Sections
object Search : AppDestinations("search", Icons.Default.Search, "Search")
object Models : AppDestinations("models", Icons.Default.Layers, "Models")
object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv")
object Train : AppDestinations("train", Icons.Default.TrackChanges, "Train")
object Tags : AppDestinations("tags", Icons.Default.LocalOffer, "Tags")
// Utility/Secondary Sections
object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload")
object Settings : AppDestinations("settings", Icons.Default.Settings, "Settings")
}
// Lists used by the AppDrawerContent to render the menu sections easily
val mainDrawerItems = listOf(
AppDestinations.Search,
AppDestinations.Models,
AppDestinations.Inventory,
AppDestinations.Train,
AppDestinations.Tags
)
val utilityDrawerItems = listOf(
AppDestinations.Upload,
AppDestinations.Settings
)

View File

@@ -1,57 +0,0 @@
// In presentation/MainScreen.kt
package com.placeholder.sherpai2.presentation
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import com.placeholder.sherpai2.navigation.AppDestinations
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
// State to track which screen is currently visible
//var currentScreen by remember { mutableStateOf(AppDestinations.Search) }
var currentScreen: AppDestinations by remember { mutableStateOf(AppDestinations.Search) }
// ModalNavigationDrawer provides the left sidebar UI/UX
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
// The content of the drawer (AppDrawerContent)
AppDrawerContent(
currentScreen = currentScreen,
onDestinationClicked = { destination ->
currentScreen = destination
scope.launch { drawerState.close() } // Close drawer after selection
}
)
},
) {
// The main content area
Scaffold(
topBar = {
TopAppBar(
title = { Text(currentScreen.label) },
// Button to open the drawer
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Open Drawer")
}
}
)
}
) { paddingValues ->
// Displays the content for the currently selected screen
MainContentArea(
currentScreen = currentScreen,
modifier = Modifier.padding(paddingValues)
)
}
}
}

View File

@@ -1,59 +0,0 @@
// In presentation/AppDrawerContent.kt
package com.placeholder.sherpai2.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.placeholder.sherpai2.navigation.AppDestinations
import com.placeholder.sherpai2.navigation.mainDrawerItems
import com.placeholder.sherpai2.navigation.utilityDrawerItems
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDrawerContent(
currentScreen: AppDestinations,
onDestinationClicked: (AppDestinations) -> Unit
) {
// Defines the width and content of the sliding drawer panel
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
// Header/Logo Area
Text(
"SherpAI Control Panel",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp)
)
Divider(Modifier.fillMaxWidth())
// 1. Main Navigation Items
Column(modifier = Modifier.padding(vertical = 8.dp)) {
mainDrawerItems.forEach { destination ->
NavigationDrawerItem(
label = { Text(destination.label) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
selected = destination == currentScreen,
onClick = { onDestinationClicked(destination) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
// Separator
Divider(Modifier.fillMaxWidth().padding(vertical = 8.dp))
// 2. Utility Items
Column(modifier = Modifier.padding(vertical = 8.dp)) {
utilityDrawerItems.forEach { destination ->
NavigationDrawerItem(
label = { Text(destination.label) },
icon = { Icon(destination.icon, contentDescription = destination.label) },
selected = destination == currentScreen,
onClick = { onDestinationClicked(destination) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
}

View File

@@ -1,42 +0,0 @@
// In presentation/MainContentArea.kt
package com.placeholder.sherpai2.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.placeholder.sherpai2.navigation.AppDestinations
@Composable
fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
// Swaps the UI content based on the selected screen from the drawer
when (currentScreen) {
AppDestinations.Search -> SimplePlaceholder("Search Screen: Find your models and data.")
AppDestinations.Models -> SimplePlaceholder("Models Screen: Manage your LoRA/embeddings.")
AppDestinations.Inventory -> SimplePlaceholder("Inventory Screen: View all collected data.")
AppDestinations.Train -> SimplePlaceholder("Train Screen: Start the LoRA adaptation process.")
AppDestinations.Tags -> SimplePlaceholder("Tags Screen: Create and edit custom tags.")
AppDestinations.Upload -> SimplePlaceholder("Upload Screen: Import new photos/data.")
AppDestinations.Settings -> SimplePlaceholder("Settings Screen: Configure app behavior.")
}
}
}
@Composable
private fun SimplePlaceholder(text: String) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
}

View File

@@ -0,0 +1,336 @@
package com.placeholder.sherpai2.ui.album
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.ui.search.DateRange
import com.placeholder.sherpai2.ui.search.DisplayMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
/**
* AlbumViewModel - Display photos from a specific album (tag, person, or time range)
*
* Features:
* - Search within album
* - Date filtering
* - Simple/Verbose toggle
* - Album stats
*/
@HiltViewModel
class AlbumViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val imageDao: ImageDao,
private val personDao: PersonDao,
private val faceRecognitionRepository: FaceRecognitionRepository
) : ViewModel() {
// Album parameters from navigation
private val albumType: String = savedStateHandle["albumType"] ?: "tag"
private val albumId: String = savedStateHandle["albumId"] ?: ""
// UI state
private val _uiState = MutableStateFlow<AlbumUiState>(AlbumUiState.Loading)
val uiState: StateFlow<AlbumUiState> = _uiState.asStateFlow()
// Search query within album
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Date range filter
private val _dateRange = MutableStateFlow(DateRange.ALL_TIME)
val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow()
// Display mode
private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE)
val displayMode: StateFlow<DisplayMode> = _displayMode.asStateFlow()
init {
loadAlbumData()
}
/**
* Load album data based on type
*/
private fun loadAlbumData() {
viewModelScope.launch {
try {
_uiState.value = AlbumUiState.Loading
when (albumType) {
"tag" -> loadTagAlbum()
"person" -> loadPersonAlbum()
"time" -> loadTimeAlbum()
else -> _uiState.value = AlbumUiState.Error("Unknown album type")
}
} catch (e: Exception) {
_uiState.value = AlbumUiState.Error(e.message ?: "Failed to load album")
}
}
}
private suspend fun loadTagAlbum() {
val tag = tagDao.getByValue(albumId)
if (tag == null) {
_uiState.value = AlbumUiState.Error("Tag not found")
return
}
combine(
_searchQuery,
_dateRange
) { query, dateRange ->
Pair(query, dateRange)
}.collectLatest { (query, dateRange) ->
val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f)
val images = imageDao.getImagesByIds(imageIds)
val filteredImages = images
.filter { isInDateRange(it.capturedAt, dateRange) }
.filter {
query.isBlank() || containsQuery(it, query)
}
val imagesWithFaces = filteredImages.map { image ->
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId)
AlbumPhoto(
image = image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
val uniquePersons = imagesWithFaces
.flatMap { it.persons }
.distinctBy { it.id }
_uiState.value = AlbumUiState.Success(
albumName = tag.value.replace("_", " ").capitalize(),
albumType = "Tag",
photos = imagesWithFaces,
personCount = uniquePersons.size,
totalFaces = imagesWithFaces.sumOf { it.faceTags.size }
)
}
}
private suspend fun loadPersonAlbum() {
val person = personDao.getPersonById(albumId)
if (person == null) {
_uiState.value = AlbumUiState.Error("Person not found")
return
}
combine(
_searchQuery,
_dateRange
) { query, dateRange ->
Pair(query, dateRange)
}.collectLatest { (query, dateRange) ->
val images = faceRecognitionRepository.getImagesForPerson(albumId)
val filteredImages = images
.filter { isInDateRange(it.capturedAt, dateRange) }
.filter {
query.isBlank() || containsQuery(it, query)
}
val imagesWithFaces = filteredImages.map { image ->
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId)
AlbumPhoto(
image = image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
_uiState.value = AlbumUiState.Success(
albumName = person.name,
albumType = "Person",
photos = imagesWithFaces,
personCount = 1,
totalFaces = imagesWithFaces.sumOf { it.faceTags.size }
)
}
}
private suspend fun loadTimeAlbum() {
// Time-based albums (Today, This Week, etc)
val (startTime, endTime, albumName) = when (albumId) {
"today" -> Triple(getStartOfDay(), System.currentTimeMillis(), "Today")
"week" -> Triple(getStartOfWeek(), System.currentTimeMillis(), "This Week")
"month" -> Triple(getStartOfMonth(), System.currentTimeMillis(), "This Month")
"year" -> Triple(getStartOfYear(), System.currentTimeMillis(), "This Year")
else -> {
_uiState.value = AlbumUiState.Error("Unknown time range")
return
}
}
combine(
_searchQuery,
_dateRange
) { query, _ ->
query
}.collectLatest { query ->
val images = imageDao.getImagesInRange(startTime, endTime)
val filteredImages = images.filter {
query.isBlank() || containsQuery(it, query)
}
val imagesWithFaces = filteredImages.map { image ->
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId)
AlbumPhoto(
image = image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
val uniquePersons = imagesWithFaces
.flatMap { it.persons }
.distinctBy { it.id }
_uiState.value = AlbumUiState.Success(
albumName = albumName,
albumType = "Time",
photos = imagesWithFaces,
personCount = uniquePersons.size,
totalFaces = imagesWithFaces.sumOf { it.faceTags.size }
)
}
}
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun setDateRange(range: DateRange) {
_dateRange.value = range
}
fun toggleDisplayMode() {
_displayMode.value = when (_displayMode.value) {
DisplayMode.SIMPLE -> DisplayMode.VERBOSE
DisplayMode.VERBOSE -> DisplayMode.SIMPLE
}
}
private fun isInDateRange(timestamp: Long, range: DateRange): Boolean {
return when (range) {
DateRange.ALL_TIME -> true
DateRange.TODAY -> isToday(timestamp)
DateRange.THIS_WEEK -> isThisWeek(timestamp)
DateRange.THIS_MONTH -> isThisMonth(timestamp)
DateRange.THIS_YEAR -> isThisYear(timestamp)
}
}
private fun containsQuery(image: ImageEntity, query: String): Boolean {
// Could expand to search by person names, tags, etc.
return true
}
private fun isToday(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == date.get(Calendar.DAY_OF_YEAR)
}
private fun isThisWeek(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.WEEK_OF_YEAR) == date.get(Calendar.WEEK_OF_YEAR)
}
private fun isThisMonth(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.MONTH) == date.get(Calendar.MONTH)
}
private fun isThisYear(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR)
}
private fun getStartOfDay(): Long {
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfWeek(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfMonth(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfYear(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_YEAR, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}
}
sealed class AlbumUiState {
object Loading : AlbumUiState()
data class Success(
val albumName: String,
val albumType: String,
val photos: List<AlbumPhoto>,
val personCount: Int,
val totalFaces: Int
) : AlbumUiState()
data class Error(val message: String) : AlbumUiState()
}
data class AlbumPhoto(
val image: ImageEntity,
val faceTags: List<PhotoFaceTagEntity>,
val persons: List<PersonEntity>
)

View File

@@ -0,0 +1,358 @@
package com.placeholder.sherpai2.ui.album
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.search.DateRange
import com.placeholder.sherpai2.ui.search.DisplayMode
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
/**
* AlbumViewScreen - Beautiful album detail view
*
* Features:
* - Album stats
* - Search within album
* - Date filtering
* - Simple/Verbose toggle
* - Clean person display
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumViewScreen(
onBack: () -> Unit,
onImageClick: (String) -> Unit,
viewModel: AlbumViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle()
val dateRange by viewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by viewModel.displayMode.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
when (val state = uiState) {
is AlbumUiState.Success -> {
Text(
text = state.albumName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "${state.photos.size} photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
else -> {
Text("Album")
}
}
}
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { viewModel.toggleDisplayMode() }) {
Icon(
imageVector = if (displayMode == DisplayMode.SIMPLE) {
Icons.Default.ViewList
} else {
Icons.Default.ViewModule
},
contentDescription = "Toggle view"
)
}
}
)
}
) { paddingValues ->
when (val state = uiState) {
is AlbumUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is AlbumUiState.Error -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(state.message)
Button(onClick = onBack) {
Text("Go Back")
}
}
}
}
is AlbumUiState.Success -> {
AlbumContent(
state = state,
searchQuery = searchQuery,
dateRange = dateRange,
displayMode = displayMode,
onSearchChange = { viewModel.setSearchQuery(it) },
onDateRangeChange = { viewModel.setDateRange(it) },
onImageClick = onImageClick,
modifier = Modifier.padding(paddingValues)
)
}
}
}
}
@Composable
private fun AlbumContent(
state: AlbumUiState.Success,
searchQuery: String,
dateRange: DateRange,
displayMode: DisplayMode,
onSearchChange: (String) -> Unit,
onDateRangeChange: (DateRange) -> Unit,
onImageClick: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize()
) {
// Stats card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceAround
) {
StatItem(Icons.Default.Photo, "Photos", state.photos.size.toString())
if (state.totalFaces > 0) {
StatItem(Icons.Default.Face, "Faces", state.totalFaces.toString())
}
if (state.personCount > 0) {
StatItem(Icons.Default.People, "People", state.personCount.toString())
}
}
}
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChange,
placeholder = { Text("Search in album...") },
leadingIcon = { Icon(Icons.Default.Search, null) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchChange("") }) {
Icon(Icons.Default.Clear, "Clear")
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
singleLine = true,
shape = RoundedCornerShape(16.dp)
)
Spacer(Modifier.height(8.dp))
// Date filters
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DateRange.entries) { range ->
val isActive = dateRange == range
FilterChip(
selected = isActive,
onClick = { onDateRangeChange(range) },
label = { Text(range.displayName) }
)
}
}
Spacer(Modifier.height(8.dp))
// Photo grid
if (state.photos.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No photos in this album",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = state.photos,
key = { it.image.imageId }
) { photo ->
PhotoCard(
photo = photo,
displayMode = displayMode,
onImageClick = onImageClick
)
}
}
}
}
}
@Composable
private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = value,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun PhotoCard(
photo: AlbumPhoto,
displayMode: DisplayMode,
onImageClick: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
) {
Column {
ImageGridItem(
image = photo.image,
onClick = { onImageClick(photo.image.imageUri) }
)
if (photo.persons.isNotEmpty()) {
when (displayMode) {
DisplayMode.SIMPLE -> {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = photo.persons.take(3).joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
DisplayMode.VERBOSE -> {
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
photo.persons.take(3).forEachIndexed { index, person ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Face,
null,
Modifier.size(14.dp),
MaterialTheme.colorScheme.primary
)
Text(
text = person.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (index < photo.faceTags.size) {
val confidence = (photo.faceTags[index].confidence * 100).toInt()
Text(
text = "$confidence%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,162 @@
package com.placeholder.sherpai2.ui.devscreens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Beautiful placeholder screen for features under development
*
* Shows:
* - Feature name
* - Description
* - "Coming Soon" indicator
* - Consistent styling with rest of app
*/
@Composable
fun DummyScreen(
title: String,
subtitle: String = "This feature is under development"
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
)
),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(48.dp)
) {
// Icon badge
Surface(
modifier = Modifier.size(96.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.primaryContainer,
shadowElevation = 8.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Construction,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Title
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
// Subtitle
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Coming soon badge
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.tertiaryContainer,
shadowElevation = 2.dp
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Schedule,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onTertiaryContainer
)
Text(
text = "Coming Soon",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Feature preview card
Card(
modifier = Modifier.fillMaxWidth(0.8f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "What's planned:",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
FeatureItem("Full implementation")
FeatureItem("Beautiful UI design")
FeatureItem("Smooth animations")
FeatureItem("Production-ready code")
}
}
}
}
}
@Composable
private fun FeatureItem(text: String) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View File

@@ -0,0 +1,459 @@
package com.placeholder.sherpai2.ui.explore
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
/**
* ExploreScreen - REDESIGNED
*
* Features:
* - Rectangular album cards (more compact)
* - Stories section (recent highlights)
* - Clickable navigation to AlbumViewScreen
* - Beautiful gradients and icons
*/
@Composable
fun ExploreScreen(
onAlbumClick: (albumType: String, albumId: String) -> Unit,
viewModel: ExploreViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
) {
// Header with gradient
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.surface
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Explore",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Your photo collection organized",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
when (val state = uiState) {
is ExploreViewModel.ExploreUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ExploreViewModel.ExploreUiState.Success -> {
ExploreContent(
smartAlbums = state.smartAlbums,
onAlbumClick = onAlbumClick
)
}
is ExploreViewModel.ExploreUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = state.message,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
}
@Composable
private fun ExploreContent(
smartAlbums: List<SmartAlbum>,
onAlbumClick: (albumType: String, albumId: String) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
// Stories Section (Recent Highlights)
item {
StoriesSection(
albums = smartAlbums.filter { it.imageCount > 0 }.take(10),
onAlbumClick = onAlbumClick
)
}
// Time-based Albums
val timeAlbums = smartAlbums.filterIsInstance<SmartAlbum.TimeRange>()
if (timeAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "📅 Time Capsules",
albums = timeAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Face-based Albums
val faceAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("group_photo", "selfie", "couple") }
if (faceAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "👥 People & Groups",
albums = faceAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Relationship Albums
val relationshipAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("family", "friend", "colleague") }
if (relationshipAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "❤️ Relationships",
albums = relationshipAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Time of Day Albums
val timeOfDayAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("morning", "afternoon", "evening", "night") }
if (timeOfDayAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "🌅 Times of Day",
albums = timeOfDayAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Scene Albums
val sceneAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("indoor", "outdoor") }
if (sceneAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "🏞️ Scenes",
albums = sceneAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Special Occasions
val specialAlbums = smartAlbums.filterIsInstance<SmartAlbum.Tagged>()
.filter { it.tagValue in listOf("birthday", "high_res") }
if (specialAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "⭐ Special",
albums = specialAlbums,
onAlbumClick = onAlbumClick
)
}
}
// Person Albums
val personAlbums = smartAlbums.filterIsInstance<SmartAlbum.Person>()
if (personAlbums.isNotEmpty()) {
item {
AlbumSection(
title = "👤 People",
albums = personAlbums,
onAlbumClick = onAlbumClick
)
}
}
}
}
/**
* Stories section - Instagram-style circular highlights
*/
@Composable
private fun StoriesSection(
albums: List<SmartAlbum>,
onAlbumClick: (albumType: String, albumId: String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "📖 Stories",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
items(albums) { album ->
StoryCircle(
album = album,
onClick = {
val (type, id) = getAlbumNavigation(album)
onAlbumClick(type, id)
}
)
}
}
}
}
/**
* Story circle - circular album preview
*/
@Composable
private fun StoryCircle(
album: SmartAlbum,
onClick: () -> Unit
) {
val (icon, gradient) = getAlbumIconAndGradient(album)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.clickable(onClick = onClick)
) {
Box(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
.background(gradient),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
Text(
text = album.displayName,
style = MaterialTheme.typography.labelSmall,
maxLines = 2,
modifier = Modifier.width(80.dp),
fontWeight = FontWeight.Medium
)
Text(
text = "${album.imageCount}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Album section with horizontal scrolling rectangular cards
*/
@Composable
private fun AlbumSection(
title: String,
albums: List<SmartAlbum>,
onAlbumClick: (albumType: String, albumId: String) -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
items(albums) { album ->
AlbumCard(
album = album,
onClick = {
val (type, id) = getAlbumNavigation(album)
onAlbumClick(type, id)
}
)
}
}
}
}
/**
* Rectangular album card - more compact than square
*/
@Composable
private fun AlbumCard(
album: SmartAlbum,
onClick: () -> Unit
) {
val (icon, gradient) = getAlbumIconAndGradient(album)
Card(
modifier = Modifier
.width(180.dp)
.height(120.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(gradient)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.SpaceBetween
) {
// Icon
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(32.dp)
)
// Album info
Column {
Text(
text = album.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1
)
Text(
text = "${album.imageCount} ${if (album.imageCount == 1) "photo" else "photos"}",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.9f)
)
}
}
}
}
}
/**
* Get navigation parameters for album
*/
private fun getAlbumNavigation(album: SmartAlbum): Pair<String, String> {
return when (album) {
is SmartAlbum.TimeRange.Today -> "time" to "today"
is SmartAlbum.TimeRange.ThisWeek -> "time" to "week"
is SmartAlbum.TimeRange.ThisMonth -> "time" to "month"
is SmartAlbum.TimeRange.LastYear -> "time" to "year"
is SmartAlbum.Tagged -> "tag" to album.tagValue
is SmartAlbum.Person -> "person" to album.personId
}
}
/**
* Get icon and gradient for album type
*/
private fun getAlbumIconAndGradient(album: SmartAlbum): Pair<ImageVector, Brush> {
return when (album) {
is SmartAlbum.TimeRange.Today -> Icons.Default.Today to gradientBlue()
is SmartAlbum.TimeRange.ThisWeek -> Icons.Default.DateRange to gradientTeal()
is SmartAlbum.TimeRange.ThisMonth -> Icons.Default.CalendarMonth to gradientGreen()
is SmartAlbum.TimeRange.LastYear -> Icons.Default.HistoryEdu to gradientPurple()
is SmartAlbum.Tagged -> when (album.tagValue) {
"group_photo" -> Icons.Default.Group to gradientOrange()
"selfie" -> Icons.Default.CameraAlt to gradientPink()
"couple" -> Icons.Default.Favorite to gradientRed()
"family" -> Icons.Default.FamilyRestroom to gradientIndigo()
"friend" -> Icons.Default.People to gradientCyan()
"colleague" -> Icons.Default.BusinessCenter to gradientGray()
"morning" -> Icons.Default.WbSunny to gradientYellow()
"afternoon" -> Icons.Default.LightMode to gradientOrange()
"evening" -> Icons.Default.WbTwilight to gradientOrange()
"night" -> Icons.Default.NightsStay to gradientDarkBlue()
"outdoor" -> Icons.Default.Landscape to gradientGreen()
"indoor" -> Icons.Default.Home to gradientBrown()
"birthday" -> Icons.Default.Cake to gradientPink()
"high_res" -> Icons.Default.HighQuality to gradientGold()
else -> Icons.Default.Label to gradientBlue()
}
is SmartAlbum.Person -> Icons.Default.Person to gradientPurple()
}
}
// Gradient helpers
private fun gradientBlue() = Brush.linearGradient(listOf(Color(0xFF1976D2), Color(0xFF1565C0)))
private fun gradientTeal() = Brush.linearGradient(listOf(Color(0xFF00897B), Color(0xFF00796B)))
private fun gradientGreen() = Brush.linearGradient(listOf(Color(0xFF388E3C), Color(0xFF2E7D32)))
private fun gradientPurple() = Brush.linearGradient(listOf(Color(0xFF7B1FA2), Color(0xFF6A1B9A)))
private fun gradientOrange() = Brush.linearGradient(listOf(Color(0xFFF57C00), Color(0xFFE64A19)))
private fun gradientPink() = Brush.linearGradient(listOf(Color(0xFFD81B60), Color(0xFFC2185B)))
private fun gradientRed() = Brush.linearGradient(listOf(Color(0xFFE53935), Color(0xFFD32F2F)))
private fun gradientIndigo() = Brush.linearGradient(listOf(Color(0xFF3949AB), Color(0xFF303F9F)))
private fun gradientCyan() = Brush.linearGradient(listOf(Color(0xFF00ACC1), Color(0xFF0097A7)))
private fun gradientGray() = Brush.linearGradient(listOf(Color(0xFF616161), Color(0xFF424242)))
private fun gradientYellow() = Brush.linearGradient(listOf(Color(0xFFFDD835), Color(0xFFFBC02D)))
private fun gradientDarkBlue() = Brush.linearGradient(listOf(Color(0xFF283593), Color(0xFF1A237E)))
private fun gradientBrown() = Brush.linearGradient(listOf(Color(0xFF5D4037), Color(0xFF4E342E)))
private fun gradientGold() = Brush.linearGradient(listOf(Color(0xFFFFB300), Color(0xFFFFA000)))

View File

@@ -0,0 +1,302 @@
package com.placeholder.sherpai2.ui.explore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
private val imageDao: ImageDao,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val personDao: PersonDao,
private val faceRecognitionRepository: FaceRecognitionRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<ExploreUiState>(ExploreUiState.Loading)
val uiState: StateFlow<ExploreUiState> = _uiState.asStateFlow()
sealed class ExploreUiState {
object Loading : ExploreUiState()
data class Success(
val smartAlbums: List<SmartAlbum>
) : ExploreUiState()
data class Error(val message: String) : ExploreUiState()
}
init {
loadExploreData()
}
fun loadExploreData() {
viewModelScope.launch {
try {
_uiState.value = ExploreUiState.Loading
val smartAlbums = buildSmartAlbums()
_uiState.value = ExploreUiState.Success(
smartAlbums = smartAlbums
)
} catch (e: Exception) {
_uiState.value = ExploreUiState.Error(
e.message ?: "Failed to load explore data"
)
}
}
}
private suspend fun buildSmartAlbums(): List<SmartAlbum> {
val albums = mutableListOf<SmartAlbum>()
// Time-based albums
albums.add(SmartAlbum.TimeRange.Today)
albums.add(SmartAlbum.TimeRange.ThisWeek)
albums.add(SmartAlbum.TimeRange.ThisMonth)
albums.add(SmartAlbum.TimeRange.LastYear)
// Face-based albums (from system tags)
val groupPhotoTag = tagDao.getByValue("group_photo")
if (groupPhotoTag != null) {
val count = tagDao.getTagUsageCount(groupPhotoTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("group_photo", "Group Photos", count))
}
}
val selfieTag = tagDao.getByValue("selfie")
if (selfieTag != null) {
val count = tagDao.getTagUsageCount(selfieTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("selfie", "Selfies", count))
}
}
val coupleTag = tagDao.getByValue("couple")
if (coupleTag != null) {
val count = tagDao.getTagUsageCount(coupleTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("couple", "Couples", count))
}
}
// Relationship albums
val familyTag = tagDao.getByValue("family")
if (familyTag != null) {
val count = tagDao.getTagUsageCount(familyTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("family", "Family Moments", count))
}
}
val friendTag = tagDao.getByValue("friend")
if (friendTag != null) {
val count = tagDao.getTagUsageCount(friendTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("friend", "With Friends", count))
}
}
val colleagueTag = tagDao.getByValue("colleague")
if (colleagueTag != null) {
val count = tagDao.getTagUsageCount(colleagueTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("colleague", "Work Events", count))
}
}
// Time of day albums
val morningTag = tagDao.getByValue("morning")
if (morningTag != null) {
val count = tagDao.getTagUsageCount(morningTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("morning", "Morning Moments", count))
}
}
val eveningTag = tagDao.getByValue("evening")
if (eveningTag != null) {
val count = tagDao.getTagUsageCount(eveningTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("evening", "Golden Hour", count))
}
}
val nightTag = tagDao.getByValue("night")
if (nightTag != null) {
val count = tagDao.getTagUsageCount(nightTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("night", "Night Life", count))
}
}
// Scene albums
val outdoorTag = tagDao.getByValue("outdoor")
if (outdoorTag != null) {
val count = tagDao.getTagUsageCount(outdoorTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("outdoor", "Outdoor Adventures", count))
}
}
val indoorTag = tagDao.getByValue("indoor")
if (indoorTag != null) {
val count = tagDao.getTagUsageCount(indoorTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("indoor", "Indoor Moments", count))
}
}
// Special occasions
val birthdayTag = tagDao.getByValue("birthday")
if (birthdayTag != null) {
val count = tagDao.getTagUsageCount(birthdayTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("birthday", "Birthdays", count))
}
}
// Quality albums
val highResTag = tagDao.getByValue("high_res")
if (highResTag != null) {
val count = tagDao.getTagUsageCount(highResTag.tagId)
if (count > 0) {
albums.add(SmartAlbum.Tagged("high_res", "Best Quality", count))
}
}
// Person albums
val persons = personDao.getAllPersons()
persons.forEach { person ->
val stats = faceRecognitionRepository.getPersonFaceStats(person.id)
if (stats != null && stats.taggedPhotoCount > 0) {
albums.add(SmartAlbum.Person(
personId = person.id,
personName = person.name,
imageCount = stats.taggedPhotoCount
))
}
}
return albums
}
/**
* Get images for a specific smart album
*/
suspend fun getImagesForAlbum(album: SmartAlbum): List<ImageEntity> {
return when (album) {
is SmartAlbum.TimeRange.Today -> {
val startOfDay = getStartOfDay()
imageDao.getImagesInRange(startOfDay, System.currentTimeMillis())
}
is SmartAlbum.TimeRange.ThisWeek -> {
val startOfWeek = getStartOfWeek()
imageDao.getImagesInRange(startOfWeek, System.currentTimeMillis())
}
is SmartAlbum.TimeRange.ThisMonth -> {
val startOfMonth = getStartOfMonth()
imageDao.getImagesInRange(startOfMonth, System.currentTimeMillis())
}
is SmartAlbum.TimeRange.LastYear -> {
val oneYearAgo = System.currentTimeMillis() - (365L * 24 * 60 * 60 * 1000)
imageDao.getImagesInRange(oneYearAgo, System.currentTimeMillis())
}
is SmartAlbum.Tagged -> {
val tag = tagDao.getByValue(album.tagValue)
if (tag != null) {
val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f)
imageDao.getImagesByIds(imageIds)
} else {
emptyList()
}
}
is SmartAlbum.Person -> {
faceRecognitionRepository.getImagesForPerson(album.personId)
}
}
}
private fun getStartOfDay(): Long {
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfWeek(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
private fun getStartOfMonth(): Long {
return Calendar.getInstance().apply {
set(Calendar.DAY_OF_MONTH, 1)
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
}
/**
* Smart album types
*/
sealed class SmartAlbum {
abstract val displayName: String
abstract val imageCount: Int
sealed class TimeRange : SmartAlbum() {
data object Today : TimeRange() {
override val displayName = "Today"
override val imageCount = 0 // Calculated dynamically
}
data object ThisWeek : TimeRange() {
override val displayName = "This Week"
override val imageCount = 0
}
data object ThisMonth : TimeRange() {
override val displayName = "This Month"
override val imageCount = 0
}
data object LastYear : TimeRange() {
override val displayName = "Last Year"
override val imageCount = 0
}
}
data class Tagged(
val tagValue: String,
override val displayName: String,
override val imageCount: Int
) : SmartAlbum()
data class Person(
val personId: String,
val personName: String,
override val imageCount: Int
) : SmartAlbum() {
override val displayName = personName
}
}

View File

@@ -0,0 +1,86 @@
package com.placeholder.sherpai2.ui.imagedetail
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.placeholder.sherpai2.ui.imagedetail.viewmodel.ImageDetailViewModel
/**
* ImageDetailScreen
*
* Purpose:
* - Add tags
* - Remove tags
* - Validate write propagation
*/
@Composable
fun ImageDetailScreen(
modifier: Modifier = Modifier,
imageUri: String,
onBack: () -> Unit
) {
val viewModel: ImageDetailViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
LaunchedEffect(imageUri) {
viewModel.loadImage(imageUri)
}
val tags by viewModel.tags.collectAsStateWithLifecycle()
var newTag by remember { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.padding(12.dp)
) {
AsyncImage(
model = imageUri,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = newTag,
onValueChange = { newTag = it },
label = { Text("Add tag") },
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
viewModel.addTag(newTag)
newTag = ""
},
modifier = Modifier.padding(top = 8.dp)
) {
Text("Add Tag")
}
Spacer(modifier = Modifier.height(16.dp))
tags.forEach { tag ->
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(tag.value)
TextButton(onClick = { viewModel.removeTag(tag) }) {
Text("Remove")
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
package com.placeholder.sherpai2.ui.imagedetail.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.domain.repository.TaggingRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* ImageDetailViewModel
*
* Owns:
* - Image context
* - Tag write operations
*/
@HiltViewModel
@OptIn(ExperimentalCoroutinesApi::class)
class ImageDetailViewModel @Inject constructor(
private val tagRepository: TaggingRepository
) : ViewModel() {
private val imageUri = MutableStateFlow<String?>(null)
val tags: StateFlow<List<TagEntity>> =
imageUri
.filterNotNull()
.flatMapLatest { uri ->
tagRepository.getTagsForImage(uri)
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
fun loadImage(uri: String) {
imageUri.value = uri
}
fun addTag(value: String) {
val uri = imageUri.value ?: return
viewModelScope.launch {
tagRepository.addTagToImage(uri, value, source = "MANUAL", confidence = 1.0f)
}
}
fun removeTag(tag: TagEntity) {
val uri = imageUri.value ?: return
viewModelScope.launch {
tagRepository.removeTagFromImage(uri, tag.value)
}
}
}

View File

@@ -0,0 +1,614 @@
package com.placeholder.sherpai2.ui.modelinventory
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* PersonInventoryScreen - Manage trained face models
*
* Features:
* - List all trained persons
* - View stats
* - DELETE models
* - SCAN LIBRARY to find person in all photos (NEW!)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersonInventoryScreen(
modifier: Modifier = Modifier,
viewModel: PersonInventoryViewModel = hiltViewModel(),
onViewPersonPhotos: (String) -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsState()
val scanningState by viewModel.scanningState.collectAsState()
var personToDelete by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) }
var personToScan by remember { mutableStateOf<PersonInventoryViewModel.PersonWithStats?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Trained People") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
actions = {
IconButton(onClick = { viewModel.loadPersons() }) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
}
)
}
) { paddingValues ->
Box(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (val state = uiState) {
is PersonInventoryViewModel.InventoryUiState.Loading -> {
LoadingView()
}
is PersonInventoryViewModel.InventoryUiState.Success -> {
if (state.persons.isEmpty()) {
EmptyView()
} else {
PersonListView(
persons = state.persons,
onDeleteClick = { personToDelete = it },
onScanClick = { personToScan = it },
onViewPhotos = { onViewPersonPhotos(it.person.id) },
scanningState = scanningState
)
}
}
is PersonInventoryViewModel.InventoryUiState.Error -> {
ErrorView(
message = state.message,
onRetry = { viewModel.loadPersons() }
)
}
}
// Scanning overlay
if (scanningState is PersonInventoryViewModel.ScanningState.Scanning) {
ScanningOverlay(scanningState as PersonInventoryViewModel.ScanningState.Scanning)
}
}
}
// Delete confirmation dialog
personToDelete?.let { personWithStats ->
AlertDialog(
onDismissRequest = { personToDelete = null },
title = { Text("Delete ${personWithStats.person.name}?") },
text = {
Text(
"This will delete the face model and all ${personWithStats.stats.taggedPhotoCount} " +
"face tags. Your photos will NOT be deleted."
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.deletePerson(
personWithStats.person.id,
personWithStats.stats.faceModelId
)
personToDelete = null
},
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { personToDelete = null }) {
Text("Cancel")
}
}
)
}
// Scan library confirmation dialog
personToScan?.let { personWithStats ->
AlertDialog(
onDismissRequest = { personToScan = null },
icon = { Icon(Icons.Default.Search, contentDescription = null) },
title = { Text("Scan Library for ${personWithStats.person.name}?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"This will scan your entire photo library and automatically tag " +
"all photos containing ${personWithStats.person.name}."
)
Text(
"Currently tagged: ${personWithStats.stats.taggedPhotoCount} photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
Button(
onClick = {
viewModel.scanLibraryForPerson(
personWithStats.person.id,
personWithStats.stats.faceModelId
)
personToScan = null
}
) {
Icon(Icons.Default.Search, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Start Scan")
}
},
dismissButton = {
TextButton(onClick = { personToScan = null }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun LoadingView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator()
Text(
text = "Loading trained models...",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
private fun EmptyView() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
)
Text(
text = "No trained people yet",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Train a person using 10+ photos to start recognizing faces",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ErrorView(
message: String,
onRetry: () -> Unit
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Text(
text = "Error",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Retry")
}
}
}
}
@Composable
private fun PersonListView(
persons: List<PersonInventoryViewModel.PersonWithStats>,
onDeleteClick: (PersonInventoryViewModel.PersonWithStats) -> Unit,
onScanClick: (PersonInventoryViewModel.PersonWithStats) -> Unit,
onViewPhotos: (PersonInventoryViewModel.PersonWithStats) -> Unit,
scanningState: PersonInventoryViewModel.ScanningState
) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Summary card
item {
SummaryCard(totalPersons = persons.size)
Spacer(modifier = Modifier.height(8.dp))
}
// Person cards
items(persons) { personWithStats ->
PersonCard(
personWithStats = personWithStats,
onDeleteClick = { onDeleteClick(personWithStats) },
onScanClick = { onScanClick(personWithStats) },
onViewPhotos = { onViewPhotos(personWithStats) },
isScanning = scanningState is PersonInventoryViewModel.ScanningState.Scanning &&
scanningState.personId == personWithStats.person.id
)
}
}
}
@Composable
private fun SummaryCard(totalPersons: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = "$totalPersons trained ${if (totalPersons == 1) "person" else "people"}",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Face recognition models ready",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
}
}
@Composable
private fun PersonCard(
personWithStats: PersonInventoryViewModel.PersonWithStats,
onDeleteClick: () -> Unit,
onScanClick: () -> Unit,
onViewPhotos: () -> Unit,
isScanning: Boolean
) {
val stats = personWithStats.stats
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header: Name and actions
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Text(
text = personWithStats.person.name.take(1).uppercase(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
)
}
Column {
Text(
text = personWithStats.person.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "ID: ${personWithStats.person.id.take(8)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = onDeleteClick) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Stats grid
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
icon = Icons.Default.PhotoCamera,
label = "Training",
value = "${stats.trainingImageCount}"
)
StatItem(
icon = Icons.Default.AccountBox,
label = "Tagged",
value = "${stats.taggedPhotoCount}"
)
StatItem(
icon = Icons.Default.CheckCircle,
label = "Confidence",
value = "${(stats.averageConfidence * 100).toInt()}%",
valueColor = if (stats.averageConfidence >= 0.8f) {
MaterialTheme.colorScheme.primary
} else if (stats.averageConfidence >= 0.6f) {
MaterialTheme.colorScheme.tertiary
} else {
MaterialTheme.colorScheme.error
}
)
}
Spacer(modifier = Modifier.height(16.dp))
// Last detected
stats.lastDetectedAt?.let { timestamp ->
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.DateRange,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Last detected: ${formatDate(timestamp)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// Action buttons row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Scan Library button (PRIMARY ACTION)
Button(
onClick = onScanClick,
modifier = Modifier.weight(1f),
enabled = !isScanning,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
if (isScanning) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
color = MaterialTheme.colorScheme.onPrimary,
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(if (isScanning) "Scanning..." else "Scan Library")
}
// View photos button
if (stats.taggedPhotoCount > 0) {
OutlinedButton(
onClick = onViewPhotos,
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Photo,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("View (${stats.taggedPhotoCount})")
}
}
}
}
}
}
@Composable
private fun StatItem(
icon: ImageVector,
label: String,
value: String,
valueColor: Color = MaterialTheme.colorScheme.primary
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = valueColor
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Scanning overlay showing progress
*/
@Composable
private fun ScanningOverlay(state: PersonInventoryViewModel.ScanningState.Scanning) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Scanning Library",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Finding ${state.personName} in your photos...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
LinearProgressIndicator(
progress = { state.progress / state.total.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
Text(
text = "${state.progress} / ${state.total} photos scanned",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "${state.facesFound} faces detected",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
private fun formatDate(timestamp: Long): String {
val formatter = SimpleDateFormat("MMM d, yyyy h:mm a", Locale.getDefault())
return formatter.format(Date(timestamp))
}

View File

@@ -0,0 +1,349 @@
package com.placeholder.sherpai2.ui.modelinventory
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.data.repository.PersonFaceStats
import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.ml.ThresholdStrategy
import com.placeholder.sherpai2.ml.ImageQuality
import com.placeholder.sherpai2.ml.DetectionContext
import com.placeholder.sherpai2.util.DebugFlags
import com.placeholder.sherpai2.util.DiagnosticLogger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
/**
* PersonInventoryViewModel - Enhanced with smart threshold strategy
*
* Toggle diagnostics in DebugFlags.kt:
* - ENABLE_FACE_RECOGNITION_LOGGING = true/false
* - USE_LIBERAL_THRESHOLDS = true/false
*/
@HiltViewModel
class PersonInventoryViewModel @Inject constructor(
application: Application,
private val faceRecognitionRepository: FaceRecognitionRepository,
private val imageRepository: ImageRepository
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow<InventoryUiState>(InventoryUiState.Loading)
val uiState: StateFlow<InventoryUiState> = _uiState.asStateFlow()
private val _scanningState = MutableStateFlow<ScanningState>(ScanningState.Idle)
val scanningState: StateFlow<ScanningState> = _scanningState.asStateFlow()
private val faceDetector by lazy {
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.10f)
.build()
FaceDetection.getClient(options)
}
data class PersonWithStats(
val person: PersonEntity,
val stats: PersonFaceStats
)
sealed class InventoryUiState {
object Loading : InventoryUiState()
data class Success(val persons: List<PersonWithStats>) : InventoryUiState()
data class Error(val message: String) : InventoryUiState()
}
sealed class ScanningState {
object Idle : ScanningState()
data class Scanning(
val personId: String,
val personName: String,
val progress: Int,
val total: Int,
val facesFound: Int,
val facesDetected: Int = 0
) : ScanningState()
data class Complete(
val personName: String,
val facesFound: Int,
val imagesScanned: Int,
val totalFacesDetected: Int = 0
) : ScanningState()
}
init {
loadPersons()
}
fun loadPersons() {
viewModelScope.launch {
try {
_uiState.value = InventoryUiState.Loading
val persons = faceRecognitionRepository.getPersonsWithFaceModels()
val personsWithStats = persons.mapNotNull { person ->
val stats = faceRecognitionRepository.getPersonFaceStats(person.id)
if (stats != null) {
PersonWithStats(person, stats)
} else {
null
}
}.sortedByDescending { it.stats.taggedPhotoCount }
_uiState.value = InventoryUiState.Success(personsWithStats)
} catch (e: Exception) {
_uiState.value = InventoryUiState.Error(
e.message ?: "Failed to load persons"
)
}
}
}
fun deletePerson(personId: String, faceModelId: String) {
viewModelScope.launch {
try {
faceRecognitionRepository.deleteFaceModel(faceModelId)
loadPersons()
} catch (e: Exception) {
_uiState.value = InventoryUiState.Error(
"Failed to delete: ${e.message}"
)
}
}
}
/**
* Scan library with SMART threshold selection
*/
fun scanLibraryForPerson(personId: String, faceModelId: String) {
viewModelScope.launch {
try {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
DiagnosticLogger.i("=== STARTING LIBRARY SCAN (ENHANCED) ===")
DiagnosticLogger.i("PersonId: $personId")
DiagnosticLogger.i("FaceModelId: $faceModelId")
}
val currentState = _uiState.value
val person = if (currentState is InventoryUiState.Success) {
currentState.persons.find { it.person.id == personId }?.person
} else null
val personName = person?.name ?: "Unknown"
// Get face model to determine training count
val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId)
val trainingCount = faceModel?.trainingImageCount ?: 15
DiagnosticLogger.i("Training count: $trainingCount")
val allImages = imageRepository.getAllImages().first()
val totalImages = allImages.size
DiagnosticLogger.i("Total images in library: $totalImages")
_scanningState.value = ScanningState.Scanning(
personId = personId,
personName = personName,
progress = 0,
total = totalImages,
facesFound = 0,
facesDetected = 0
)
var facesFound = 0
var totalFacesDetected = 0
allImages.forEachIndexed { index, imageWithEverything ->
val image = imageWithEverything.image
DiagnosticLogger.d("--- Image ${index + 1}/$totalImages ---")
DiagnosticLogger.d("ImageId: ${image.imageId}")
// Detect faces with ML Kit
val detectedFaces = detectFacesInImage(image.imageUri)
totalFacesDetected += detectedFaces.size
DiagnosticLogger.d("Faces detected: ${detectedFaces.size}")
if (detectedFaces.isNotEmpty()) {
// ENHANCED: Calculate image quality
val imageQuality = ThresholdStrategy.estimateImageQuality(
width = image.width,
height = image.height
)
// ENHANCED: Estimate detection context
val detectionContext = ThresholdStrategy.estimateDetectionContext(
faceCount = detectedFaces.size,
faceAreaRatio = if (detectedFaces.isNotEmpty()) {
calculateFaceAreaRatio(detectedFaces[0], image.width, image.height)
} else 0f
)
// ENHANCED: Get smart threshold
val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) {
ThresholdStrategy.getLiberalThreshold(trainingCount)
} else {
ThresholdStrategy.getOptimalThreshold(
trainingCount = trainingCount,
imageQuality = imageQuality,
detectionContext = detectionContext
)
}
DiagnosticLogger.d("Quality: $imageQuality, Context: $detectionContext")
DiagnosticLogger.d("Using threshold: $scanThreshold")
// Scan image with smart threshold
val tags = faceRecognitionRepository.scanImage(
imageId = image.imageId,
detectedFaces = detectedFaces,
threshold = scanThreshold
)
DiagnosticLogger.d("Tags created: ${tags.size}")
tags.forEach { tag ->
DiagnosticLogger.d(" Tag: model=${tag.faceModelId.take(8)}, conf=${String.format("%.3f", tag.confidence)}")
}
val matchingTags = tags.filter { it.faceModelId == faceModelId }
DiagnosticLogger.d("Matching tags for target: ${matchingTags.size}")
facesFound += matchingTags.size
}
_scanningState.value = ScanningState.Scanning(
personId = personId,
personName = personName,
progress = index + 1,
total = totalImages,
facesFound = facesFound,
facesDetected = totalFacesDetected
)
}
DiagnosticLogger.i("=== SCAN COMPLETE ===")
DiagnosticLogger.i("Images scanned: $totalImages")
DiagnosticLogger.i("Faces detected: $totalFacesDetected")
DiagnosticLogger.i("Faces matched: $facesFound")
DiagnosticLogger.i("Hit rate: ${if (totalFacesDetected > 0) (facesFound * 100 / totalFacesDetected) else 0}%")
_scanningState.value = ScanningState.Complete(
personName = personName,
facesFound = facesFound,
imagesScanned = totalImages,
totalFacesDetected = totalFacesDetected
)
loadPersons()
delay(3000)
_scanningState.value = ScanningState.Idle
} catch (e: Exception) {
DiagnosticLogger.e("Scan failed", e)
_scanningState.value = ScanningState.Idle
_uiState.value = InventoryUiState.Error(
"Scan failed: ${e.message}"
)
}
}
}
private suspend fun detectFacesInImage(imageUri: String): List<DetectedFace> = withContext(Dispatchers.Default) {
try {
val uri = Uri.parse(imageUri)
val inputStream = getApplication<Application>().contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) {
DiagnosticLogger.w("Failed to load bitmap from: $imageUri")
return@withContext emptyList()
}
DiagnosticLogger.d("Bitmap: ${bitmap.width}x${bitmap.height}")
val image = InputImage.fromBitmap(bitmap, 0)
val faces = faceDetector.process(image).await()
DiagnosticLogger.d("ML Kit found ${faces.size} faces")
faces.mapNotNull { face ->
val boundingBox = face.boundingBox
val croppedFace = try {
val left = boundingBox.left.coerceAtLeast(0)
val top = boundingBox.top.coerceAtLeast(0)
val width = boundingBox.width().coerceAtMost(bitmap.width - left)
val height = boundingBox.height().coerceAtMost(bitmap.height - top)
if (width > 0 && height > 0) {
Bitmap.createBitmap(bitmap, left, top, width, height)
} else {
null
}
} catch (e: Exception) {
DiagnosticLogger.e("Face crop failed", e)
null
}
if (croppedFace != null) {
DetectedFace(
croppedBitmap = croppedFace,
boundingBox = boundingBox
)
} else {
null
}
}
} catch (e: Exception) {
DiagnosticLogger.e("Face detection failed: $imageUri", e)
emptyList()
}
}
/**
* Calculate face area ratio (for context detection)
*/
private fun calculateFaceAreaRatio(
face: DetectedFace,
imageWidth: Int,
imageHeight: Int
): Float {
val faceArea = face.boundingBox.width() * face.boundingBox.height()
val imageArea = imageWidth * imageHeight
return faceArea.toFloat() / imageArea.toFloat()
}
suspend fun getPersonImages(personId: String) =
faceRecognitionRepository.getImagesForPerson(personId)
override fun onCleared() {
super.onCleared()
faceDetector.close()
}
}

View File

@@ -0,0 +1,157 @@
package com.placeholder.sherpai2.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
/**
* AppDestinations - Navigation metadata for drawer UI
*
* Clean, organized structure:
* - Routes for navigation
* - Icons for visual identity
* - Labels for display
* - Descriptions for clarity
* - Grouped by function
*/
sealed class AppDestinations(
val route: String,
val icon: ImageVector,
val label: String,
val description: String = ""
) {
// ==================
// PHOTO BROWSING
// ==================
data object Search : AppDestinations(
route = AppRoutes.SEARCH,
icon = Icons.Default.Search,
label = "Search",
description = "Find photos by tag or person"
)
data object Explore : AppDestinations(
route = AppRoutes.EXPLORE,
icon = Icons.Default.Explore,
label = "Explore",
description = "Browse smart albums"
)
// ImageDetail is not in drawer (internal navigation only)
// ==================
// FACE RECOGNITION
// ==================
data object Inventory : AppDestinations(
route = AppRoutes.INVENTORY,
icon = Icons.Default.Face,
label = "People",
description = "Trained face models"
)
data object Train : AppDestinations(
route = AppRoutes.TRAIN,
icon = Icons.Default.ModelTraining,
label = "Train",
description = "Train new person"
)
data object Models : AppDestinations(
route = AppRoutes.MODELS,
icon = Icons.Default.SmartToy,
label = "Models",
description = "AI model management"
)
// ==================
// ORGANIZATION
// ==================
data object Tags : AppDestinations(
route = AppRoutes.TAGS,
icon = Icons.AutoMirrored.Filled.Label,
label = "Tags",
description = "Manage photo tags"
)
data object Upload : AppDestinations(
route = AppRoutes.UPLOAD,
icon = Icons.Default.UploadFile,
label = "Upload",
description = "Add new photos"
)
// ==================
// SETTINGS
// ==================
data object Settings : AppDestinations(
route = AppRoutes.SETTINGS,
icon = Icons.Default.Settings,
label = "Settings",
description = "App preferences"
)
}
/**
* Organized destination groups for beautiful drawer sections
*/
// Photo browsing section
val photoDestinations = listOf(
AppDestinations.Search,
AppDestinations.Explore
)
// Face recognition section
val faceRecognitionDestinations = listOf(
AppDestinations.Inventory,
AppDestinations.Train,
AppDestinations.Models
)
// Organization section
val organizationDestinations = listOf(
AppDestinations.Tags,
AppDestinations.Upload
)
// Settings (separate, pinned to bottom)
val settingsDestination = AppDestinations.Settings
/**
* All drawer items (excludes Settings which is handled separately)
*/
val allMainDrawerDestinations = photoDestinations + faceRecognitionDestinations + organizationDestinations
/**
* Helper function to get destination by route
* Useful for highlighting current route in drawer
*/
fun getDestinationByRoute(route: String?): AppDestinations? {
return when (route) {
AppRoutes.SEARCH -> AppDestinations.Search
AppRoutes.EXPLORE -> AppDestinations.Explore
AppRoutes.INVENTORY -> AppDestinations.Inventory
AppRoutes.TRAIN -> AppDestinations.Train
AppRoutes.MODELS -> AppDestinations.Models
AppRoutes.TAGS -> AppDestinations.Tags
AppRoutes.UPLOAD -> AppDestinations.Upload
AppRoutes.SETTINGS -> AppDestinations.Settings
else -> null
}
}
/**
* Legacy support (for backwards compatibility)
* These match your old structure
*/
@Deprecated("Use organized groups instead", ReplaceWith("allMainDrawerDestinations"))
val mainDrawerItems = allMainDrawerDestinations
@Deprecated("Use settingsDestination instead", ReplaceWith("listOf(settingsDestination)"))
val utilityDrawerItems = listOf(settingsDestination)

View File

@@ -0,0 +1,251 @@
package com.placeholder.sherpai2.ui.navigation
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.placeholder.sherpai2.ui.devscreens.DummyScreen
import com.placeholder.sherpai2.ui.explore.ExploreScreen
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen
import com.placeholder.sherpai2.ui.search.SearchScreen
import com.placeholder.sherpai2.ui.search.SearchViewModel
import com.placeholder.sherpai2.ui.tags.TagManagementScreen
import com.placeholder.sherpai2.ui.trainingprep.ImageSelectorScreen
import com.placeholder.sherpai2.ui.trainingprep.ScanResultsScreen
import com.placeholder.sherpai2.ui.trainingprep.ScanningState
import com.placeholder.sherpai2.ui.trainingprep.TrainViewModel
import com.placeholder.sherpai2.ui.trainingprep.TrainingScreen
import java.net.URLDecoder
import java.net.URLEncoder
/**
* AppNavHost - Main navigation graph
* UPDATED: Added Explore and Tags screens
*
* Complete flow:
* - Photo browsing (Search, Explore, Detail)
* - Face recognition (Inventory, Train)
* - Organization (Tags, Upload)
* - Settings
*
* Features:
* - URL encoding for safe navigation
* - Proper back stack management
* - State preservation
* - Beautiful placeholders
*/
@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = AppRoutes.SEARCH,
modifier = modifier
) {
// ==========================================
// PHOTO BROWSING
// ==========================================
/**
* SEARCH SCREEN
* Main photo browser with face tag search
*/
composable(AppRoutes.SEARCH) {
val searchViewModel: SearchViewModel = hiltViewModel()
SearchScreen(
searchViewModel = searchViewModel,
onImageClick = { imageUri ->
val encodedUri = URLEncoder.encode(imageUri, "UTF-8")
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri")
}
)
}
/**
* EXPLORE SCREEN
* Browse smart albums (auto-generated from tags)
*/
composable(AppRoutes.EXPLORE) {
ExploreScreen(
onAlbumClick = { albumType, albumId ->
println("Album clicked: type=$albumType id=$albumId")
// Example future navigation
// navController.navigate("${AppRoutes.ALBUM}/$albumType/$albumId")
}
)
}
/**
* IMAGE DETAIL SCREEN
* Single photo view with metadata
*/
composable(
route = "${AppRoutes.IMAGE_DETAIL}/{imageUri}",
arguments = listOf(
navArgument("imageUri") {
type = NavType.StringType
}
)
) { backStackEntry ->
val imageUri = backStackEntry.arguments?.getString("imageUri")
?.let { URLDecoder.decode(it, "UTF-8") }
?: error("imageUri missing from navigation")
ImageDetailScreen(
imageUri = imageUri,
onBack = { navController.popBackStack() }
)
}
// ==========================================
// FACE RECOGNITION SYSTEM
// ==========================================
/**
* PERSON INVENTORY SCREEN
* View all trained face models
*
* Features:
* - List all trained people
* - Show stats (training count, tagged photos, confidence)
* - Delete models
* - View photos containing each person
*/
composable(AppRoutes.INVENTORY) {
PersonInventoryScreen(
onViewPersonPhotos = { personId ->
// Navigate back to search
// TODO: In future, add person filter to search screen
navController.navigate(AppRoutes.SEARCH)
}
)
}
/**
* TRAINING FLOW
* Train new face recognition model
*
* Flow:
* 1. TrainingScreen (select images button)
* 2. ImageSelectorScreen (pick 15-50 photos)
* 3. ScanResultsScreen (validation + name input)
* 4. Training completes → navigate to Inventory
*/
composable(AppRoutes.TRAIN) { entry ->
val trainViewModel: TrainViewModel = hiltViewModel()
val uiState by trainViewModel.uiState.collectAsState()
// Get images selected from ImageSelector
val selectedUris = entry.savedStateHandle.get<List<Uri>>("selected_image_uris")
// Start scanning when new images are selected
LaunchedEffect(selectedUris) {
if (selectedUris != null && uiState is ScanningState.Idle) {
trainViewModel.scanAndTagFaces(selectedUris)
entry.savedStateHandle.remove<List<Uri>>("selected_image_uris")
}
}
when (uiState) {
is ScanningState.Idle -> {
// Show start screen with "Select Images" button
TrainingScreen(
onSelectImages = {
navController.navigate(AppRoutes.IMAGE_SELECTOR)
}
)
}
else -> {
// Show validation results and training UI
ScanResultsScreen(
state = uiState,
onFinish = {
// After training, go to inventory to see new person
navController.navigate(AppRoutes.INVENTORY) {
popUpTo(AppRoutes.TRAIN) { inclusive = true }
}
}
)
}
}
}
/**
* IMAGE SELECTOR SCREEN
* Pick images for training (internal screen)
*/
composable(AppRoutes.IMAGE_SELECTOR) {
ImageSelectorScreen(
onImagesSelected = { uris ->
// Pass selected URIs back to Train screen
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_image_uris", uris)
navController.popBackStack()
}
)
}
/**
* MODELS SCREEN
* AI model management (placeholder)
*/
composable(AppRoutes.MODELS) {
DummyScreen(
title = "AI Models",
subtitle = "Manage face recognition models"
)
}
// ==========================================
// ORGANIZATION
// ==========================================
/**
* TAGS SCREEN
* Manage photo tags with auto-tagging features
*/
composable(AppRoutes.TAGS) {
TagManagementScreen()
}
/**
* UPLOAD SCREEN
* Import new photos (placeholder)
*/
composable(AppRoutes.UPLOAD) {
DummyScreen(
title = "Upload",
subtitle = "Add photos to your library"
)
}
// ==========================================
// SETTINGS
// ==========================================
/**
* SETTINGS SCREEN
* App preferences (placeholder)
*/
composable(AppRoutes.SETTINGS) {
DummyScreen(
title = "Settings",
subtitle = "App preferences and configuration"
)
}
}
}

View File

@@ -0,0 +1,36 @@
package com.placeholder.sherpai2.ui.navigation
/**
* Centralized list of navigation routes used by NavHost.
*
* This intentionally mirrors AppDestinations.route
* but exists as a pure navigation concern.
*
* Why:
* - Drawer UI ≠ Navigation system
* - Keeps NavHost decoupled from icons / labels
*/
object AppRoutes {
// Photo browsing
const val SEARCH = "search"
const val EXPLORE = "explore" // UPDATED: Changed from TOUR
const val IMAGE_DETAIL = "IMAGE_DETAIL"
// Face recognition
const val INVENTORY = "inv"
const val TRAIN = "train"
const val MODELS = "models"
// Organization
const val TAGS = "tags"
const val UPLOAD = "upload"
// Settings
const val SETTINGS = "settings"
// Internal training flow screens
const val IMAGE_SELECTOR = "Image Selection"
const val CROP_SCREEN = "CROP_SCREEN"
const val TRAINING_SCREEN = "TRAINING_SCREEN"
const val ScanResultsScreen = "First Scan Results"
}

View File

@@ -0,0 +1,243 @@
package com.placeholder.sherpai2.ui.presentation
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Label
import androidx.compose.material.icons.filled.*
import com.placeholder.sherpai2.ui.navigation.AppRoutes
/**
* Beautiful app drawer with sections, gradient header, and polish
* UPDATED: Tour → Explore
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDrawerContent(
currentRoute: String?,
onDestinationClicked: (String) -> Unit
) {
ModalDrawerSheet(
modifier = Modifier.width(300.dp),
drawerContainerColor = MaterialTheme.colorScheme.surface
) {
Column(modifier = Modifier.fillMaxSize()) {
// ===== BEAUTIFUL GRADIENT HEADER =====
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.surface
)
)
)
.padding(24.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// App icon/logo area
Surface(
modifier = Modifier.size(56.dp),
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Text(
"SherpAI",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
"Face Recognition System",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// ===== NAVIGATION SECTIONS =====
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Photos Section
DrawerSection(title = "Photos")
val photoItems = listOf(
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search, "Find photos by tag or person"),
DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore, "Browse smart albums")
)
photoItems.forEach { item ->
DrawerNavigationItem(
item = item,
selected = item.route == currentRoute,
onClick = { onDestinationClicked(item.route) }
)
}
Spacer(modifier = Modifier.height(8.dp))
// Face Recognition Section
DrawerSection(title = "Face Recognition")
val faceItems = listOf(
DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face, "Trained face models"),
DrawerItem(AppRoutes.TRAIN, "Train", Icons.Default.ModelTraining, "Train new person"),
DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy, "AI model management")
)
faceItems.forEach { item ->
DrawerNavigationItem(
item = item,
selected = item.route == currentRoute,
onClick = { onDestinationClicked(item.route) }
)
}
Spacer(modifier = Modifier.height(8.dp))
// Organization Section
DrawerSection(title = "Organization")
val orgItems = listOf(
DrawerItem(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label, "Manage photo tags"),
DrawerItem(AppRoutes.UPLOAD, "Upload", Icons.Default.UploadFile, "Add new photos")
)
orgItems.forEach { item ->
DrawerNavigationItem(
item = item,
selected = item.route == currentRoute,
onClick = { onDestinationClicked(item.route) }
)
}
Spacer(modifier = Modifier.weight(1f))
// Settings at bottom
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
DrawerNavigationItem(
item = DrawerItem(
AppRoutes.SETTINGS,
"Settings",
Icons.Default.Settings,
"App preferences"
),
selected = AppRoutes.SETTINGS == currentRoute,
onClick = { onDestinationClicked(AppRoutes.SETTINGS) }
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
/**
* Section header in drawer
*/
@Composable
private fun DrawerSection(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
/**
* Individual navigation item with icon, label, and subtitle
*/
@Composable
private fun DrawerNavigationItem(
item: DrawerItem,
selected: Boolean,
onClick: () -> Unit
) {
NavigationDrawerItem(
label = {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = item.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
item.subtitle?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
},
icon = {
Icon(
item.icon,
contentDescription = item.label,
modifier = Modifier.size(24.dp)
)
},
selected = selected,
onClick = onClick,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
.clip(RoundedCornerShape(12.dp)),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
unselectedContainerColor = Color.Transparent
)
)
}
/**
* Data class for drawer items
*/
private data class DrawerItem(
val route: String,
val label: String,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val subtitle: String? = null
)

View File

@@ -0,0 +1,219 @@
package com.placeholder.sherpai2.ui.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.placeholder.sherpai2.ui.navigation.AppNavHost
import com.placeholder.sherpai2.ui.navigation.AppRoutes
import kotlinx.coroutines.launch
/**
* Beautiful main screen with gradient header, dynamic actions, and polish
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: AppRoutes.SEARCH
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawerContent(
currentRoute = currentRoute,
onDestinationClicked = { route ->
scope.launch {
drawerState.close()
if (route != currentRoute) {
navController.navigate(route) {
launchSingleTop = true
}
}
}
}
)
},
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = getScreenTitle(currentRoute),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
getScreenSubtitle(currentRoute)?.let { subtitle ->
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
navigationIcon = {
IconButton(
onClick = { scope.launch { drawerState.open() } }
) {
Icon(
Icons.Default.Menu,
contentDescription = "Open Menu",
tint = MaterialTheme.colorScheme.primary
)
}
},
actions = {
// Dynamic actions based on current screen
when (currentRoute) {
AppRoutes.SEARCH -> {
IconButton(onClick = { /* TODO: Open filter dialog */ }) {
Icon(
Icons.Default.FilterList,
contentDescription = "Filter",
tint = MaterialTheme.colorScheme.primary
)
}
}
AppRoutes.INVENTORY -> {
IconButton(onClick = {
navController.navigate(AppRoutes.TRAIN)
}) {
Icon(
Icons.Default.PersonAdd,
contentDescription = "Add Person",
tint = MaterialTheme.colorScheme.primary
)
}
}
AppRoutes.TAGS -> {
IconButton(onClick = { /* TODO: Add tag */ }) {
Icon(
Icons.Default.Add,
contentDescription = "Add Tag",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.primary,
actionIconContentColor = MaterialTheme.colorScheme.primary
)
)
},
floatingActionButton = {
// Dynamic FAB based on screen
AnimatedVisibility(
visible = shouldShowFab(currentRoute),
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
) {
when (currentRoute) {
AppRoutes.SEARCH -> {
ExtendedFloatingActionButton(
onClick = { /* TODO: Advanced search */ },
icon = {
Icon(Icons.Default.Tune, "Advanced Search")
},
text = { Text("Filters") },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
AppRoutes.TAGS -> {
FloatingActionButton(
onClick = { /* TODO: Add new tag */ },
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Icon(Icons.Default.Add, "Add Tag")
}
}
AppRoutes.UPLOAD -> {
ExtendedFloatingActionButton(
onClick = { /* TODO: Select photos */ },
icon = { Icon(Icons.Default.CloudUpload, "Upload") },
text = { Text("Select Photos") },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
else -> {
// No FAB for other screens
}
}
}
}
) { paddingValues ->
AppNavHost(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}
/**
* Get human-readable screen title
*/
private fun getScreenTitle(route: String): String {
return when (route) {
AppRoutes.SEARCH -> "Search"
AppRoutes.EXPLORE -> "Explore" // Will be renamed to EXPLORE
AppRoutes.INVENTORY -> "People"
AppRoutes.TRAIN -> "Train New Person"
AppRoutes.MODELS -> "AI Models"
AppRoutes.TAGS -> "Tag Management"
AppRoutes.UPLOAD -> "Upload Photos"
AppRoutes.SETTINGS -> "Settings"
else -> "SherpAI"
}
}
/**
* Get subtitle for screens that need context
*/
private fun getScreenSubtitle(route: String): String? {
return when (route) {
AppRoutes.SEARCH -> "Find photos by tags, people, or date"
AppRoutes.EXPLORE -> "Browse your collection"
AppRoutes.INVENTORY -> "Trained face models"
AppRoutes.TRAIN -> "Add a new person to recognize"
AppRoutes.TAGS -> "Organize your photo collection"
AppRoutes.UPLOAD -> "Add photos to your library"
else -> null
}
}
/**
* Determine if FAB should be shown for current screen
*/
private fun shouldShowFab(route: String): Boolean {
return when (route) {
AppRoutes.SEARCH,
AppRoutes.TAGS,
AppRoutes.UPLOAD -> true
else -> false
}
}

View File

@@ -0,0 +1,478 @@
package com.placeholder.sherpai2.ui.search
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.ui.search.components.ImageGridItem
/**
* SearchScreen - COMPLETE REDESIGN
*
* Features:
* - Near-match search ("low" → "low_res")
* - Quick tag filter chips
* - Date range filtering
* - Clean person-only display
* - Simple/Verbose toggle
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
modifier: Modifier = Modifier,
searchViewModel: SearchViewModel,
onImageClick: (String) -> Unit,
onAlbumClick: (String) -> Unit = {} // For opening album view
) {
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val activeTagFilters by searchViewModel.activeTagFilters.collectAsStateWithLifecycle()
val dateRange by searchViewModel.dateRange.collectAsStateWithLifecycle()
val displayMode by searchViewModel.displayMode.collectAsStateWithLifecycle()
val systemTags by searchViewModel.systemTags.collectAsStateWithLifecycle()
val images by searchViewModel
.searchImages()
.collectAsStateWithLifecycle(initialValue = emptyList())
Scaffold { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Header with gradient
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.surface
)
)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Title
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Column {
Text(
text = "Search Photos",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Near-match • Filters • Smart tags",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Simple/Verbose toggle
IconButton(
onClick = { searchViewModel.toggleDisplayMode() }
) {
Icon(
imageVector = if (displayMode == DisplayMode.SIMPLE) {
Icons.Default.ViewList
} else {
Icons.Default.ViewModule
},
contentDescription = "Toggle view mode",
tint = MaterialTheme.colorScheme.primary
)
}
}
// Search bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchViewModel.setSearchQuery(it) },
placeholder = { Text("Search... (e.g., 'low', 'gro', 'nig')") },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null)
},
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchViewModel.setSearchQuery("") }) {
Icon(Icons.Default.Clear, contentDescription = "Clear")
}
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = RoundedCornerShape(16.dp)
)
}
}
// Quick Tag Filters
if (systemTags.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Quick Filters",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
if (activeTagFilters.isNotEmpty()) {
TextButton(onClick = { searchViewModel.clearTagFilters() }) {
Text("Clear all")
}
}
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(systemTags) { tag ->
val isActive = tag.value in activeTagFilters
FilterChip(
selected = isActive,
onClick = { searchViewModel.toggleTagFilter(tag.value) },
label = {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = getTagEmoji(tag.value),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = tag.value.replace("_", " "),
style = MaterialTheme.typography.bodySmall
)
}
},
leadingIcon = if (isActive) {
{ Icon(Icons.Default.Check, null, Modifier.size(16.dp)) }
} else null
)
}
}
}
}
// Date Range Filters
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(DateRange.entries) { range ->
val isActive = dateRange == range
FilterChip(
selected = isActive,
onClick = { searchViewModel.setDateRange(range) },
label = { Text(range.displayName) },
leadingIcon = if (isActive) {
{ Icon(Icons.Default.DateRange, null, Modifier.size(16.dp)) }
} else null
)
}
}
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
// Results
if (images.isEmpty() && searchQuery.isBlank() && activeTagFilters.isEmpty()) {
EmptySearchState()
} else if (images.isEmpty()) {
NoResultsState(
query = searchQuery,
hasFilters = activeTagFilters.isNotEmpty() || dateRange != DateRange.ALL_TIME
)
} else {
Column {
// Results header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${images.size} ${if (images.size == 1) "photo" else "photos"}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
// View Album button (if search results can be grouped)
if (activeTagFilters.size == 1 || searchQuery.isNotBlank()) {
TextButton(
onClick = {
val albumTag = activeTagFilters.firstOrNull() ?: searchQuery
onAlbumClick(albumTag)
}
) {
Icon(
Icons.Default.Collections,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text("View Album")
}
}
}
// Photo grid
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxSize()
) {
items(
items = images,
key = { it.image.imageId }
) { imageWithFaceTags ->
PhotoCard(
imageWithFaceTags = imageWithFaceTags,
displayMode = displayMode,
onImageClick = onImageClick
)
}
}
}
}
}
}
}
/**
* Photo card with clean person display
*/
@Composable
private fun PhotoCard(
imageWithFaceTags: ImageWithFaceTags,
displayMode: DisplayMode,
onImageClick: (String) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
// Image
ImageGridItem(
image = imageWithFaceTags.image,
onClick = { onImageClick(imageWithFaceTags.image.imageUri) }
)
// Person tags
if (imageWithFaceTags.persons.isNotEmpty()) {
when (displayMode) {
DisplayMode.SIMPLE -> {
// SIMPLE: Just names, no icons, no percentages
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
modifier = Modifier.fillMaxWidth()
) {
Text(
text = imageWithFaceTags.persons
.take(3)
.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
DisplayMode.VERBOSE -> {
// VERBOSE: Icons + names + confidence
Surface(
color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
imageWithFaceTags.persons
.take(3)
.forEachIndexed { index, person ->
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
text = person.name,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (index < imageWithFaceTags.faceTags.size) {
val confidence = (imageWithFaceTags.faceTags[index].confidence * 100).toInt()
Text(
text = "$confidence%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
if (imageWithFaceTags.persons.size > 3) {
Text(
text = "+${imageWithFaceTags.persons.size - 3} more",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}
}
}
}
@Composable
private fun EmptySearchState() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.Search,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f)
)
Text(
text = "Search or filter photos",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = "Try searching or tapping quick filters",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun NoResultsState(query: String, hasFilters: Boolean) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.padding(32.dp)
) {
Icon(
Icons.Default.SearchOff,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
)
Text(
text = "No results found",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
if (query.isNotBlank()) {
Text(
text = "No matches for \"$query\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (hasFilters) {
Text(
text = "Try removing some filters",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Get emoji for tag type
*/
private fun getTagEmoji(tagValue: String): String {
return when (tagValue) {
"night" -> "🌙"
"morning" -> "🌅"
"afternoon" -> "☀️"
"evening" -> "🌇"
"indoor" -> "🏠"
"outdoor" -> "🌲"
"group_photo" -> "👥"
"selfie" -> "🤳"
"couple" -> "💑"
"family" -> "👨‍👩‍👧"
"friend" -> "🤝"
"birthday" -> "🎂"
"high_res" -> ""
"low_res" -> "📦"
"landscape" -> "🖼️"
"portrait" -> "📱"
"square" -> ""
else -> "🏷️"
}
}

View File

@@ -0,0 +1,288 @@
package com.placeholder.sherpai2.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.domain.repository.ImageRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
/**
* SearchViewModel - COMPLETE REDESIGN
*
* Features:
* - Near-match search ("low" → "low_res", "gro" → "group_photo")
* - Date range filtering
* - Quick tag filters
* - Clean person-only display
* - Simple/Verbose toggle
*/
@HiltViewModel
class SearchViewModel @Inject constructor(
private val imageRepository: ImageRepository,
private val faceRecognitionRepository: FaceRecognitionRepository,
private val tagDao: TagDao
) : ViewModel() {
// Search query with near-match support
private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
// Active tag filters (quick chips)
private val _activeTagFilters = MutableStateFlow<Set<String>>(emptySet())
val activeTagFilters: StateFlow<Set<String>> = _activeTagFilters.asStateFlow()
// Date range filter
private val _dateRange = MutableStateFlow(DateRange.ALL_TIME)
val dateRange: StateFlow<DateRange> = _dateRange.asStateFlow()
// Display mode (simple = names only, verbose = icons + percentages)
private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE)
val displayMode: StateFlow<DisplayMode> = _displayMode.asStateFlow()
// Available system tags for quick filters
private val _systemTags = MutableStateFlow<List<TagEntity>>(emptyList())
val systemTags: StateFlow<List<TagEntity>> = _systemTags.asStateFlow()
init {
loadSystemTags()
}
/**
* Main search flow - combines query, tag filters, and date range
*/
fun searchImages(): Flow<List<ImageWithFaceTags>> {
return combine(
_searchQuery,
_activeTagFilters,
_dateRange
) { query, tagFilters, dateRange ->
Triple(query, tagFilters, dateRange)
}.flatMapLatest { (query, tagFilters, dateRange) ->
channelFlow {
// Get matching tags FIRST (suspend call)
val matchingTags = if (query.isNotBlank()) {
findMatchingTags(query)
} else {
emptyList()
}
// Get base images
val imagesFlow = when {
matchingTags.isNotEmpty() -> {
// Search by all matching tags
combine(matchingTags.map { tag ->
imageRepository.findImagesByTag(tag.value)
}) { results ->
results.flatMap { it }.distinctBy { it.image.imageId }
}
}
tagFilters.isNotEmpty() -> {
// Filter by active tags
combine(tagFilters.map { tagValue ->
imageRepository.findImagesByTag(tagValue)
}) { results ->
results.flatMap { it }.distinctBy { it.image.imageId }
}
}
else -> imageRepository.getAllImages()
}
// Apply date filtering and add face data
imagesFlow.collect { imagesList ->
val filtered = imagesList
.filter { imageWithEverything ->
isInDateRange(imageWithEverything.image.capturedAt, dateRange)
}
.map { imageWithEverything ->
// Get face tags with person info
val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(
imageWithEverything.image.imageId
)
ImageWithFaceTags(
image = imageWithEverything.image,
faceTags = tagsWithPersons.map { it.first },
persons = tagsWithPersons.map { it.second }
)
}
.sortedByDescending { it.image.capturedAt }
send(filtered)
}
}
}
}
/**
* Near-match search: "low" matches "low_res", "gro" matches "group_photo"
*/
private suspend fun findMatchingTags(query: String): List<TagEntity> {
val normalizedQuery = query.trim().lowercase()
// Get all system tags
val allTags = tagDao.getByType("SYSTEM")
// Find tags that contain the query or match it closely
return allTags.filter { tag ->
val tagValue = tag.value.lowercase()
// Exact match
tagValue == normalizedQuery ||
// Contains match
tagValue.contains(normalizedQuery) ||
// Starts with match
tagValue.startsWith(normalizedQuery) ||
// Fuzzy match (remove underscores and compare)
tagValue.replace("_", "").contains(normalizedQuery.replace("_", ""))
}.sortedBy { tag ->
// Sort by relevance: exact > starts with > contains
when {
tag.value.lowercase() == normalizedQuery -> 0
tag.value.lowercase().startsWith(normalizedQuery) -> 1
else -> 2
}
}
}
/**
* Load available system tags for quick filters
*/
private fun loadSystemTags() {
viewModelScope.launch {
val tags = tagDao.getByType("SYSTEM")
// Get usage counts for all tags
val tagsWithUsage = tags.map { tag ->
tag to tagDao.getTagUsageCount(tag.tagId)
}
// Sort by most commonly used
val sortedTags = tagsWithUsage
.sortedByDescending { (_, usageCount) -> usageCount }
.take(12) // Show top 12 most used tags
.map { (tag, _) -> tag }
_systemTags.value = sortedTags
}
}
/**
* Update search query
*/
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
/**
* Toggle a tag filter
*/
fun toggleTagFilter(tagValue: String) {
_activeTagFilters.value = if (tagValue in _activeTagFilters.value) {
_activeTagFilters.value - tagValue
} else {
_activeTagFilters.value + tagValue
}
}
/**
* Clear all tag filters
*/
fun clearTagFilters() {
_activeTagFilters.value = emptySet()
}
/**
* Set date range filter
*/
fun setDateRange(range: DateRange) {
_dateRange.value = range
}
/**
* Toggle display mode (simple/verbose)
*/
fun toggleDisplayMode() {
_displayMode.value = when (_displayMode.value) {
DisplayMode.SIMPLE -> DisplayMode.VERBOSE
DisplayMode.VERBOSE -> DisplayMode.SIMPLE
}
}
/**
* Check if timestamp is in date range
*/
private fun isInDateRange(timestamp: Long, range: DateRange): Boolean {
return when (range) {
DateRange.ALL_TIME -> true
DateRange.TODAY -> isToday(timestamp)
DateRange.THIS_WEEK -> isThisWeek(timestamp)
DateRange.THIS_MONTH -> isThisMonth(timestamp)
DateRange.THIS_YEAR -> isThisYear(timestamp)
}
}
private fun isToday(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == date.get(Calendar.DAY_OF_YEAR)
}
private fun isThisWeek(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.WEEK_OF_YEAR) == date.get(Calendar.WEEK_OF_YEAR)
}
private fun isThisMonth(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) &&
today.get(Calendar.MONTH) == date.get(Calendar.MONTH)
}
private fun isThisYear(timestamp: Long): Boolean {
val today = Calendar.getInstance()
val date = Calendar.getInstance().apply { timeInMillis = timestamp }
return today.get(Calendar.YEAR) == date.get(Calendar.YEAR)
}
}
/**
* Data class containing image with face recognition data
*/
data class ImageWithFaceTags(
val image: ImageEntity,
val faceTags: List<PhotoFaceTagEntity>,
val persons: List<PersonEntity>
)
/**
* Date range filters
*/
enum class DateRange(val displayName: String) {
ALL_TIME("All Time"),
TODAY("Today"),
THIS_WEEK("This Week"),
THIS_MONTH("This Month"),
THIS_YEAR("This Year")
}
/**
* Display modes for photo tags
*/
enum class DisplayMode {
SIMPLE, // Just person names
VERBOSE // Names + icons + confidence percentages
}

View File

@@ -0,0 +1,30 @@
package com.placeholder.sherpai2.ui.search.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import coil.compose.rememberAsyncImagePainter
import com.placeholder.sherpai2.data.local.entity.ImageEntity
/**
* ImageGridItem
*
* Minimal thumbnail preview.
* No click handling yet.
*/
@Composable
fun ImageGridItem(
image: ImageEntity,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null
) {
Image(
painter = rememberAsyncImagePainter(image.imageUri),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
}

View File

@@ -0,0 +1,624 @@
package com.placeholder.sherpai2.ui.tags
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
@Composable
fun TagManagementScreen(
viewModel: TagManagementViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val scanningState by viewModel.scanningState.collectAsState()
var showAddTagDialog by remember { mutableStateOf(false) }
var showScanMenu by remember { mutableStateOf(false) }
var searchQuery by remember { mutableStateOf("") }
Scaffold(
floatingActionButton = {
// Single extended FAB with dropdown menu
var showMenu by remember { mutableStateOf(false) }
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Dropdown menu for scan options
if (showMenu) {
Card(
modifier = Modifier.width(180.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column {
ListItem(
headlineContent = { Text("Scan All", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.AutoFixHigh,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForAllTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Base Tags", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.PhotoCamera,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForBaseTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Relationships", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.People,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForRelationshipTags()
showMenu = false
}
)
ListItem(
headlineContent = { Text("Birthdays", style = MaterialTheme.typography.bodyMedium) },
leadingContent = {
Icon(
Icons.Default.Cake,
null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
},
modifier = Modifier.clickable {
viewModel.scanForBirthdayTags()
showMenu = false
}
)
}
}
}
// Main FAB
ExtendedFloatingActionButton(
onClick = { showMenu = !showMenu },
icon = {
Icon(
if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh,
"Scan"
)
},
text = { Text(if (showMenu) "Close" else "Scan Tags") }
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Stats Bar
StatsBar(uiState)
// Search Bar
SearchBar(
searchQuery = searchQuery,
onSearchChange = {
searchQuery = it
viewModel.searchTags(it)
}
)
// Scanning Progress
AnimatedVisibility(
visible = scanningState !is TagManagementViewModel.TagScanningState.Idle,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
) {
ScanningProgress(scanningState, viewModel)
}
// Tag List
when (val state = uiState) {
is TagManagementViewModel.TagUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is TagManagementViewModel.TagUiState.Success -> {
TagList(
tags = state.tags,
onDeleteTag = { viewModel.deleteTag(it) }
)
}
is TagManagementViewModel.TagUiState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// Add Tag Dialog
if (showAddTagDialog) {
AddTagDialog(
onDismiss = { showAddTagDialog = false },
onConfirm = { tagName ->
viewModel.createUserTag(tagName)
showAddTagDialog = false
}
)
}
// Scan Menu
if (showScanMenu) {
ScanMenuDialog(
onDismiss = { showScanMenu = false },
onScanSelected = { scanType ->
when (scanType) {
TagManagementViewModel.ScanType.BASE_TAGS -> viewModel.scanForBaseTags()
TagManagementViewModel.ScanType.RELATIONSHIP_TAGS -> viewModel.scanForRelationshipTags()
TagManagementViewModel.ScanType.BIRTHDAY_TAGS -> viewModel.scanForBirthdayTags()
TagManagementViewModel.ScanType.SCENE_TAGS -> viewModel.scanForSceneTags()
TagManagementViewModel.ScanType.ALL -> viewModel.scanForAllTags()
}
showScanMenu = false
}
)
}
}
@Composable
private fun StatsBar(uiState: TagManagementViewModel.TagUiState) {
if (uiState is TagManagementViewModel.TagUiState.Success) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceAround
) {
StatItem("Total", uiState.totalTags.toString(), Icons.Default.Label)
StatItem("System", uiState.systemTags.toString(), Icons.Default.AutoAwesome)
StatItem("User", uiState.userTags.toString(), Icons.Default.PersonOutline)
}
}
}
}
@Composable
private fun StatItem(label: String, value: String, icon: ImageVector) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun SearchBar(
searchQuery: String,
onSearchChange: (String) -> Unit
) {
OutlinedTextField(
value = searchQuery,
onValueChange = onSearchChange,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
placeholder = { Text("Search tags...") },
leadingIcon = { Icon(Icons.Default.Search, "Search") },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { onSearchChange("") }) {
Icon(Icons.Default.Clear, "Clear")
}
}
},
singleLine = true
)
}
@Composable
private fun ScanningProgress(
scanningState: TagManagementViewModel.TagScanningState,
viewModel: TagManagementViewModel
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
when (scanningState) {
is TagManagementViewModel.TagScanningState.Scanning -> {
Text(
text = "Scanning: ${scanningState.scanType.name.replace("_", " ")}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { scanningState.progress.toFloat() / scanningState.total.toFloat() },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${scanningState.progress} / ${scanningState.total} images",
style = MaterialTheme.typography.bodySmall
)
Text(
text = "Tags applied: ${scanningState.tagsApplied}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
is TagManagementViewModel.TagScanningState.Complete -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "✓ Scan Complete",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "${scanningState.tagsApplied} tags applied to ${scanningState.imagesProcessed} images",
style = MaterialTheme.typography.bodySmall
)
}
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
}
}
}
is TagManagementViewModel.TagScanningState.Error -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Error: ${scanningState.message}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
IconButton(onClick = { viewModel.resetScanningState() }) {
Icon(Icons.Default.Close, "Close")
}
}
}
else -> { /* Idle - don't show */ }
}
}
}
}
@Composable
private fun TagList(
tags: List<TagWithUsage>,
onDeleteTag: (String) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(tags, key = { it.tagId }) { tag ->
TagListItem(tag, onDeleteTag)
}
}
}
@Composable
private fun TagListItem(
tag: TagWithUsage,
onDeleteTag: (String) -> Unit
) {
var showDeleteConfirm by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
onClick = { /* TODO: Navigate to images with this tag */ }
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Tag type icon
Icon(
imageVector = if (tag.type == "SYSTEM") Icons.Default.AutoAwesome else Icons.Default.Label,
contentDescription = null,
tint = if (tag.type == "SYSTEM")
MaterialTheme.colorScheme.secondary
else
MaterialTheme.colorScheme.primary
)
Column {
Text(
text = tag.value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = if (tag.type == "SYSTEM") "System tag" else "User tag",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Usage count badge
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = tag.usageCount.toString(),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
// Delete button (only for user tags)
if (tag.type == "GENERIC") {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete tag",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
if (showDeleteConfirm) {
AlertDialog(
onDismissRequest = { showDeleteConfirm = false },
title = { Text("Delete Tag?") },
text = { Text("Are you sure you want to delete '${tag.value}'? This will remove it from ${tag.usageCount} images.") },
confirmButton = {
TextButton(
onClick = {
onDeleteTag(tag.tagId)
showDeleteConfirm = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirm = false }) {
Text("Cancel")
}
}
)
}
}
@Composable
private fun AddTagDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
) {
var tagName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Add New Tag") },
text = {
OutlinedTextField(
value = tagName,
onValueChange = { tagName = it },
label = { Text("Tag name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = { onConfirm(tagName) },
enabled = tagName.isNotBlank()
) {
Text("Add")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun ScanMenuDialog(
onDismiss: () -> Unit,
onScanSelected: (TagManagementViewModel.ScanType) -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Scan for Tags") },
text = {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
ScanOption(
title = "Base Tags",
description = "Face count, orientation, time, quality",
icon = Icons.Default.PhotoCamera,
onClick = { onScanSelected(TagManagementViewModel.ScanType.BASE_TAGS) }
)
ScanOption(
title = "Relationship Tags",
description = "Family, friends, colleagues",
icon = Icons.Default.People,
onClick = { onScanSelected(TagManagementViewModel.ScanType.RELATIONSHIP_TAGS) }
)
ScanOption(
title = "Birthday Tags",
description = "Photos near birthdays",
icon = Icons.Default.Cake,
onClick = { onScanSelected(TagManagementViewModel.ScanType.BIRTHDAY_TAGS) }
)
ScanOption(
title = "Scene Tags",
description = "Indoor/outdoor detection",
icon = Icons.Default.Landscape,
onClick = { onScanSelected(TagManagementViewModel.ScanType.SCENE_TAGS) }
)
Divider()
ScanOption(
title = "Scan All",
description = "Run all scans",
icon = Icons.Default.AutoFixHigh,
onClick = { onScanSelected(TagManagementViewModel.ScanType.ALL) }
)
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}
@Composable
private fun ScanOption(
title: String,
description: String,
icon: ImageVector,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Column {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}

View File

@@ -0,0 +1,398 @@
package com.placeholder.sherpai2.ui.tags
import android.app.Application
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.data.service.AutoTaggingService
import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.util.DiagnosticLogger
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.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
@HiltViewModel
class TagManagementViewModel @Inject constructor(
application: Application,
private val tagDao: TagDao,
private val imageTagDao: ImageTagDao,
private val imageRepository: ImageRepository,
private val autoTaggingService: AutoTaggingService
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow<TagUiState>(TagUiState.Loading)
val uiState: StateFlow<TagUiState> = _uiState.asStateFlow()
private val _scanningState = MutableStateFlow<TagScanningState>(TagScanningState.Idle)
val scanningState: StateFlow<TagScanningState> = _scanningState.asStateFlow()
private val faceDetector by lazy {
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.10f)
.build()
FaceDetection.getClient(options)
}
sealed class TagUiState {
object Loading : TagUiState()
data class Success(
val tags: List<TagWithUsage>,
val totalTags: Int,
val systemTags: Int,
val userTags: Int
) : TagUiState()
data class Error(val message: String) : TagUiState()
}
sealed class TagScanningState {
object Idle : TagScanningState()
data class Scanning(
val scanType: ScanType,
val progress: Int,
val total: Int,
val tagsApplied: Int,
val currentImage: String = ""
) : TagScanningState()
data class Complete(
val scanType: ScanType,
val imagesProcessed: Int,
val tagsApplied: Int,
val newTagsCreated: Int = 0
) : TagScanningState()
data class Error(val message: String) : TagScanningState()
}
enum class ScanType {
BASE_TAGS, // Face count, orientation, resolution, time-of-day
RELATIONSHIP_TAGS, // Family, friend, colleague from person entities
BIRTHDAY_TAGS, // Birthday tags for DOB matches
SCENE_TAGS, // Indoor/outdoor estimation
ALL // Run all scans
}
init {
loadTags()
}
fun loadTags() {
viewModelScope.launch {
try {
_uiState.value = TagUiState.Loading
val tagsWithUsage = tagDao.getMostUsedTags(1000) // Get all tags
val systemTags = tagsWithUsage.count { it.type == "SYSTEM" }
val userTags = tagsWithUsage.count { it.type == "GENERIC" }
_uiState.value = TagUiState.Success(
tags = tagsWithUsage,
totalTags = tagsWithUsage.size,
systemTags = systemTags,
userTags = userTags
)
} catch (e: Exception) {
_uiState.value = TagUiState.Error(
e.message ?: "Failed to load tags"
)
}
}
}
fun createUserTag(tagName: String) {
viewModelScope.launch {
try {
val trimmedName = tagName.trim().lowercase()
if (trimmedName.isEmpty()) {
_uiState.value = TagUiState.Error("Tag name cannot be empty")
return@launch
}
// Check if tag already exists
val existing = tagDao.getByValue(trimmedName)
if (existing != null) {
_uiState.value = TagUiState.Error("Tag '$trimmedName' already exists")
return@launch
}
val newTag = TagEntity.createUserTag(trimmedName)
tagDao.insert(newTag)
loadTags()
} catch (e: Exception) {
_uiState.value = TagUiState.Error(
"Failed to create tag: ${e.message}"
)
}
}
}
fun deleteTag(tagId: String) {
viewModelScope.launch {
try {
tagDao.delete(tagId)
loadTags()
} catch (e: Exception) {
_uiState.value = TagUiState.Error(
"Failed to delete tag: ${e.message}"
)
}
}
}
fun searchTags(query: String) {
viewModelScope.launch {
try {
val results = if (query.isBlank()) {
tagDao.getMostUsedTags(1000)
} else {
tagDao.searchTagsWithUsage(query, 100)
}
val systemTags = results.count { it.type == "SYSTEM" }
val userTags = results.count { it.type == "GENERIC" }
_uiState.value = TagUiState.Success(
tags = results,
totalTags = results.size,
systemTags = systemTags,
userTags = userTags
)
} catch (e: Exception) {
_uiState.value = TagUiState.Error("Search failed: ${e.message}")
}
}
}
// ======================
// AUTO-TAGGING SCANS
// ======================
/**
* Scan library for base tags (face count, orientation, time, quality, scene)
*/
fun scanForBaseTags() {
performScan(ScanType.BASE_TAGS)
}
/**
* Scan for relationship tags (family, friend, colleague)
*/
fun scanForRelationshipTags() {
performScan(ScanType.RELATIONSHIP_TAGS)
}
/**
* Scan for birthday tags
*/
fun scanForBirthdayTags() {
performScan(ScanType.BIRTHDAY_TAGS)
}
/**
* Scan for scene tags (indoor/outdoor)
*/
fun scanForSceneTags() {
performScan(ScanType.SCENE_TAGS)
}
/**
* Scan for ALL tags
*/
fun scanForAllTags() {
performScan(ScanType.ALL)
}
private fun performScan(scanType: ScanType) {
viewModelScope.launch {
try {
DiagnosticLogger.i("=== STARTING TAG SCAN: $scanType ===")
_scanningState.value = TagScanningState.Scanning(
scanType = scanType,
progress = 0,
total = 0,
tagsApplied = 0
)
val allImages = imageRepository.getAllImages().first()
var tagsApplied = 0
var newTagsCreated = 0
DiagnosticLogger.i("Processing ${allImages.size} images")
allImages.forEachIndexed { index, imageWithEverything ->
val image = imageWithEverything.image
_scanningState.value = TagScanningState.Scanning(
scanType = scanType,
progress = index + 1,
total = allImages.size,
tagsApplied = tagsApplied,
currentImage = image.imageId.take(8)
)
when (scanType) {
ScanType.BASE_TAGS -> {
tagsApplied += scanImageForBaseTags(image.imageUri, image)
}
ScanType.SCENE_TAGS -> {
tagsApplied += scanImageForSceneTags(image.imageUri, image)
}
ScanType.RELATIONSHIP_TAGS -> {
// Handled at person level, not per-image
}
ScanType.BIRTHDAY_TAGS -> {
// Handled at person level, not per-image
}
ScanType.ALL -> {
tagsApplied += scanImageForBaseTags(image.imageUri, image)
tagsApplied += scanImageForSceneTags(image.imageUri, image)
}
}
}
// Handle person-level scans
if (scanType == ScanType.RELATIONSHIP_TAGS || scanType == ScanType.ALL) {
DiagnosticLogger.i("Scanning relationship tags...")
tagsApplied += autoTaggingService.autoTagAllRelationships()
}
if (scanType == ScanType.BIRTHDAY_TAGS || scanType == ScanType.ALL) {
DiagnosticLogger.i("Scanning birthday tags...")
tagsApplied += autoTaggingService.autoTagAllBirthdays(daysRange = 3)
}
DiagnosticLogger.i("=== SCAN COMPLETE ===")
DiagnosticLogger.i("Images processed: ${allImages.size}")
DiagnosticLogger.i("Tags applied: $tagsApplied")
_scanningState.value = TagScanningState.Complete(
scanType = scanType,
imagesProcessed = allImages.size,
tagsApplied = tagsApplied,
newTagsCreated = newTagsCreated
)
loadTags()
} catch (e: Exception) {
DiagnosticLogger.e("Scan failed", e)
_scanningState.value = TagScanningState.Error(
"Scan failed: ${e.message}"
)
}
}
}
private suspend fun scanImageForBaseTags(
imageUri: String,
image: com.placeholder.sherpai2.data.local.entity.ImageEntity
): Int = withContext(Dispatchers.Default) {
try {
val uri = Uri.parse(imageUri)
val inputStream = getApplication<Application>().contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) return@withContext 0
// Detect faces
val detectedFaces = detectFaces(bitmap)
// Auto-tag with base tags
autoTaggingService.autoTagImage(image, bitmap, detectedFaces)
} catch (e: Exception) {
DiagnosticLogger.e("Base tag scan failed for $imageUri", e)
0
}
}
private suspend fun scanImageForSceneTags(
imageUri: String,
image: com.placeholder.sherpai2.data.local.entity.ImageEntity
): Int = withContext(Dispatchers.Default) {
try {
val uri = Uri.parse(imageUri)
val inputStream = getApplication<Application>().contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) return@withContext 0
// Only auto-tag scene tags (indoor/outdoor already included in autoTagImage)
// This is a subset of base tags, so we don't need separate logic
0
} catch (e: Exception) {
DiagnosticLogger.e("Scene tag scan failed for $imageUri", e)
0
}
}
private suspend fun detectFaces(bitmap: android.graphics.Bitmap): List<DetectedFace> = withContext(Dispatchers.Default) {
try {
val image = InputImage.fromBitmap(bitmap, 0)
val faces = faceDetector.process(image).await()
faces.mapNotNull { face ->
val boundingBox = face.boundingBox
val croppedFace = try {
val left = boundingBox.left.coerceAtLeast(0)
val top = boundingBox.top.coerceAtLeast(0)
val width = boundingBox.width().coerceAtMost(bitmap.width - left)
val height = boundingBox.height().coerceAtMost(bitmap.height - top)
if (width > 0 && height > 0) {
android.graphics.Bitmap.createBitmap(bitmap, left, top, width, height)
} else {
null
}
} catch (e: Exception) {
null
}
if (croppedFace != null) {
DetectedFace(
croppedBitmap = croppedFace,
boundingBox = boundingBox
)
} else {
null
}
}
} catch (e: Exception) {
emptyList()
}
}
fun resetScanningState() {
_scanningState.value = TagScanningState.Idle
}
override fun onCleared() {
super.onCleared()
faceDetector.close()
}
}

View File

@@ -0,0 +1,77 @@
// TourScreen.kt
package com.placeholder.sherpai2.ui.tour
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
@Composable
fun TourScreen(tourViewModel: TourViewModel = hiltViewModel(), onImageClick: (String) -> Unit) {
val images by tourViewModel.recentImages.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
// Header with image count
Text(
text = "Gallery (${images.size} images)",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp)
) {
items(images) { image ->
ImageCard(image)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
}
@Composable
fun ImageCard(image: ImageWithEverything) {
Card(modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(4.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = image.tags.toString(), style = MaterialTheme.typography.bodyMedium)
// Tags row with placeholders if fewer than 3
Row(modifier = Modifier.padding(top = 8.dp)) {
val tags = image.tags.map { it.tagId } // adjust depending on your entity
tags.forEach { tag ->
TagComposable(tag)
}
repeat(3 - tags.size.coerceAtMost(3)) {
TagComposable("") // empty placeholder
}
}
}
}
}
@Composable
fun TagComposable(tag: String) {
Box(
modifier = Modifier
.padding(end = 4.dp)
.height(24.dp)
.widthIn(min = 40.dp)
.background(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.shapes.small),
contentAlignment = androidx.compose.ui.Alignment.Center
) {
Text(
text = if (tag.isNotBlank()) tag else " ",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp)
)
}
}

View File

@@ -0,0 +1,39 @@
// TourViewModel.kt
package com.placeholder.sherpai2.ui.tour
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.domain.repository.ImageRepository
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TourViewModel @Inject constructor(
private val imageRepository: ImageRepository
) : ViewModel() {
// Expose recent images as StateFlow
private val _recentImages = MutableStateFlow<List<ImageWithEverything>>(emptyList())
val recentImages: StateFlow<List<ImageWithEverything>> = _recentImages.asStateFlow()
init {
loadRecentImages()
}
private fun loadRecentImages(limit: Int = 100) {
viewModelScope.launch {
imageRepository.getRecentImages(limit)
.catch { e ->
println("TourViewModel: error fetching images: $e")
_recentImages.value = emptyList()
}
.collect { images ->
println("TourViewModel: fetched ${images.size} images")
_recentImages.value = images
}
}
}
}

View File

@@ -0,0 +1,159 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
/**
* Helper class for detecting duplicate or near-duplicate images using perceptual hashing
*/
class DuplicateImageDetector(private val context: Context) {
data class DuplicateCheckResult(
val hasDuplicates: Boolean,
val duplicateGroups: List<DuplicateGroup>,
val uniqueImageCount: Int
)
data class DuplicateGroup(
val images: List<Uri>,
val similarity: Double
)
private data class ImageHash(
val uri: Uri,
val hash: Long
)
/**
* Check for duplicate images in the provided list
*/
suspend fun checkForDuplicates(
uris: List<Uri>,
similarityThreshold: Double = 0.95
): DuplicateCheckResult = withContext(Dispatchers.Default) {
if (uris.size < 2) {
return@withContext DuplicateCheckResult(
hasDuplicates = false,
duplicateGroups = emptyList(),
uniqueImageCount = uris.size
)
}
// Compute perceptual hash for each image
val imageHashes = uris.mapNotNull { uri ->
try {
val bitmap = loadBitmap(uri)
bitmap?.let {
val hash = computePerceptualHash(it)
ImageHash(uri, hash)
}
} catch (e: Exception) {
null
}
}
// Find duplicate groups
val duplicateGroups = mutableListOf<DuplicateGroup>()
val processed = mutableSetOf<Uri>()
for (i in imageHashes.indices) {
if (imageHashes[i].uri in processed) continue
val currentGroup = mutableListOf(imageHashes[i].uri)
for (j in i + 1 until imageHashes.size) {
if (imageHashes[j].uri in processed) continue
val similarity = calculateSimilarity(imageHashes[i].hash, imageHashes[j].hash)
if (similarity >= similarityThreshold) {
currentGroup.add(imageHashes[j].uri)
processed.add(imageHashes[j].uri)
}
}
if (currentGroup.size > 1) {
duplicateGroups.add(
DuplicateGroup(
images = currentGroup,
similarity = 1.0
)
)
processed.addAll(currentGroup)
}
}
DuplicateCheckResult(
hasDuplicates = duplicateGroups.isNotEmpty(),
duplicateGroups = duplicateGroups,
uniqueImageCount = uris.size - duplicateGroups.sumOf { it.images.size - 1 }
)
}
/**
* Compute perceptual hash using difference hash (dHash) algorithm
*/
private fun computePerceptualHash(bitmap: Bitmap): Long {
// Resize to 9x8
val resized = Bitmap.createScaledBitmap(bitmap, 9, 8, false)
var hash = 0L
var bitIndex = 0
for (y in 0 until 8) {
for (x in 0 until 8) {
val leftPixel = resized.getPixel(x, y)
val rightPixel = resized.getPixel(x + 1, y)
val leftGray = toGrayscale(leftPixel)
val rightGray = toGrayscale(rightPixel)
if (leftGray > rightGray) {
hash = hash or (1L shl bitIndex)
}
bitIndex++
}
}
resized.recycle()
return hash
}
/**
* Convert RGB pixel to grayscale value
*/
private fun toGrayscale(pixel: Int): Int {
val r = (pixel shr 16) and 0xFF
val g = (pixel shr 8) and 0xFF
val b = pixel and 0xFF
return (0.299 * r + 0.587 * g + 0.114 * b).toInt()
}
/**
* Calculate similarity between two hashes
*/
private fun calculateSimilarity(hash1: Long, hash2: Long): Double {
val xor = hash1 xor hash2
val hammingDistance = xor.countOneBits()
return 1.0 - (hammingDistance / 64.0)
}
/**
* Load bitmap from URI
*/
private fun loadBitmap(uri: Uri): Bitmap? {
return try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close()
}
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,435 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Dialog for selecting a face from multiple detected faces
*/
@Composable
fun FacePickerDialog(
result: FaceDetectionHelper.FaceDetectionResult,
onDismiss: () -> Unit,
onFaceSelected: (Int, Bitmap) -> Unit // faceIndex, croppedFaceBitmap
) {
val context = LocalContext.current
var selectedFaceIndex by remember { mutableStateOf<Int?>(null) }
var croppedFaces by remember { mutableStateOf<List<Bitmap>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
// Load and crop all faces
LaunchedEffect(result) {
isLoading = true
croppedFaces = withContext(Dispatchers.IO) {
val bitmap = loadBitmapFromUri(context, result.uri)
bitmap?.let { bmp ->
result.faceBounds.map { bounds ->
cropFaceFromBitmap(bmp, bounds)
}
} ?: emptyList()
}
isLoading = false
// Auto-select the first (largest) face
if (croppedFaces.isNotEmpty()) {
selectedFaceIndex = 0
}
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth(0.95f)
.fillMaxHeight(0.9f),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Pick a Face",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "${result.faceCount} faces detected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Close")
}
}
// Instruction
Text(
text = "Tap a face below to select it for training:",
style = MaterialTheme.typography.bodyMedium
)
if (isLoading) {
// Loading state
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
// Original image with face boxes overlay
Card(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
FaceOverlayImage(
imageUri = result.uri,
faceBounds = result.faceBounds,
selectedFaceIndex = selectedFaceIndex,
onFaceClick = { index ->
selectedFaceIndex = index
}
)
}
}
// Face previews grid
Text(
text = "Preview (tap to select):",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
croppedFaces.forEachIndexed { index, faceBitmap ->
FacePreviewCard(
faceBitmap = faceBitmap,
index = index,
isSelected = selectedFaceIndex == index,
onClick = { selectedFaceIndex = index },
modifier = Modifier.weight(1f)
)
}
}
}
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
Button(
onClick = {
selectedFaceIndex?.let { index ->
if (index < croppedFaces.size) {
onFaceSelected(index, croppedFaces[index])
}
}
},
modifier = Modifier.weight(1f),
enabled = selectedFaceIndex != null && !isLoading
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Use This Face")
}
}
}
}
}
}
/**
* Image with interactive face boxes overlay
*/
@Composable
private fun FaceOverlayImage(
imageUri: Uri,
faceBounds: List<Rect>,
selectedFaceIndex: Int?,
onFaceClick: (Int) -> Unit
) {
var imageSize by remember { mutableStateOf(Size.Zero) }
var imageBounds by remember { mutableStateOf(Rect()) }
Box(
modifier = Modifier.fillMaxSize()
) {
// Original image
AsyncImage(
model = imageUri,
contentDescription = "Original image",
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
contentScale = ContentScale.Fit,
onSuccess = { state ->
val drawable = state.result.drawable
imageBounds = Rect(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
}
)
// Face boxes overlay
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
) {
if (imageBounds.width() > 0 && imageBounds.height() > 0) {
// Calculate scale to fit image in canvas
val scaleX = size.width / imageBounds.width()
val scaleY = size.height / imageBounds.height()
val scale = minOf(scaleX, scaleY)
// Calculate offset to center image
val scaledWidth = imageBounds.width() * scale
val scaledHeight = imageBounds.height() * scale
val offsetX = (size.width - scaledWidth) / 2
val offsetY = (size.height - scaledHeight) / 2
faceBounds.forEachIndexed { index, bounds ->
val isSelected = selectedFaceIndex == index
// Scale and position the face box
val left = bounds.left * scale + offsetX
val top = bounds.top * scale + offsetY
val width = bounds.width() * scale
val height = bounds.height() * scale
// Draw box
drawRect(
color = if (isSelected) Color(0xFF4CAF50) else Color(0xFF2196F3),
topLeft = Offset(left, top),
size = Size(width, height),
style = Stroke(width = if (isSelected) 6f else 4f)
)
// Draw semi-transparent fill for selected
if (isSelected) {
drawRect(
color = Color(0xFF4CAF50).copy(alpha = 0.2f),
topLeft = Offset(left, top),
size = Size(width, height)
)
}
// Draw face number label
drawCircle(
color = if (isSelected) Color(0xFF4CAF50) else Color(0xFF2196F3),
radius = 20f * scale,
center = Offset(left + 20f * scale, top + 20f * scale)
)
}
}
}
// Clickable areas for each face
faceBounds.forEachIndexed { index, bounds ->
if (imageBounds.width() > 0 && imageBounds.height() > 0) {
val scaleX = imageSize.width / imageBounds.width()
val scaleY = imageSize.height / imageBounds.height()
val scale = minOf(scaleX, scaleY)
val scaledWidth = imageBounds.width() * scale
val scaledHeight = imageBounds.height() * scale
val offsetX = (imageSize.width - scaledWidth) / 2
val offsetY = (imageSize.height - scaledHeight) / 2
Box(
modifier = Modifier
.fillMaxSize()
.clickable { onFaceClick(index) }
)
}
}
}
// Update image size
BoxWithConstraints {
LaunchedEffect(constraints) {
imageSize = Size(constraints.maxWidth.toFloat(), constraints.maxHeight.toFloat())
}
}
}
/**
* Individual face preview card
*/
@Composable
private fun FacePreviewCard(
faceBitmap: Bitmap,
index: Int,
isSelected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.aspectRatio(1f)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isSelected)
MaterialTheme.colorScheme.primaryContainer
else
MaterialTheme.colorScheme.surface
),
border = if (isSelected)
BorderStroke(3.dp, MaterialTheme.colorScheme.primary)
else
BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
) {
Box(
modifier = Modifier.fillMaxSize()
) {
androidx.compose.foundation.Image(
bitmap = faceBitmap.asImageBitmap(),
contentDescription = "Face ${index + 1}",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Selected checkmark (only show when selected)
if (isSelected) {
Surface(
modifier = Modifier
.align(Alignment.Center),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.9f)
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = "Selected",
modifier = Modifier
.padding(12.dp)
.size(32.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
// Face number badge (always in top-right, small)
Surface(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
shape = CircleShape,
color = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f),
shadowElevation = 2.dp
) {
Text(
text = "${index + 1}",
modifier = Modifier.padding(6.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = if (isSelected)
MaterialTheme.colorScheme.onPrimary
else
MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Helper function to load bitmap from URI
*/
private suspend fun loadBitmapFromUri(
context: android.content.Context,
uri: Uri
): Bitmap? = withContext(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close()
}
} catch (e: Exception) {
null
}
}
/**
* Helper function to crop face from bitmap
*/
private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
// Add 20% padding around the face
val padding = (faceBounds.width() * 0.2f).toInt()
val left = (faceBounds.left - padding).coerceAtLeast(0)
val top = (faceBounds.top - padding).coerceAtLeast(0)
val right = (faceBounds.right + padding).coerceAtMost(bitmap.width)
val bottom = (faceBounds.bottom + padding).coerceAtMost(bitmap.height)
val width = right - left
val height = bottom - top
return Bitmap.createBitmap(bitmap, left, top, width, height)
}

View File

@@ -0,0 +1,57 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.graphics.Rect
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
@Composable
fun FacePickerScreen(
uri: Uri,
faceBoxes: List<Rect>,
onFaceSelected: (Rect) -> Unit
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("Multiple faces detected!", style = MaterialTheme.typography.headlineSmall)
Text("Tap the person you want to train on.")
Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 16.dp)) {
// Main Image
Image(
painter = rememberAsyncImagePainter(uri),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Fit
)
// Overlay Clickable Boxes
// Note: In a production app, you'd need to map Rect coordinates
// from the Bitmap scale to the UI View scale.
Canvas(modifier = Modifier.fillMaxSize().clickable { /* Handle general tap */ }) {
// Implementation of coordinate mapping goes here
// TODO implement coordinate mapping
}
// Simplified: Just show the options as a list of crops if Canvas mapping is too complex for now
LazyRow {
items(faceBoxes) { box ->
Card(modifier = Modifier.padding(8.dp).size(100.dp).clickable { onFaceSelected(box) }) {
Text("Face ${faceBoxes.indexOf(box) + 1}", Modifier.align(Alignment.CenterHorizontally))
}
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.net.Uri
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import kotlinx.coroutines.tasks.await
import java.io.InputStream
/**
* Helper class for detecting faces in images using ML Kit Face Detection
*/
class FaceDetectionHelper(private val context: Context) {
private val faceDetectorOptions = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
.setMinFaceSize(0.15f) // Detect faces that are at least 15% of image
.build()
private val detector = FaceDetection.getClient(faceDetectorOptions)
data class FaceDetectionResult(
val uri: Uri,
val hasFace: Boolean,
val faceCount: Int,
val faceBounds: List<Rect> = emptyList(),
val croppedFaceBitmap: Bitmap? = null,
val errorMessage: String? = null
)
/**
* Detect faces in a single image
*/
suspend fun detectFacesInImage(uri: Uri): FaceDetectionResult {
return try {
val bitmap = loadBitmap(uri)
if (bitmap == null) {
return FaceDetectionResult(
uri = uri,
hasFace = false,
faceCount = 0,
errorMessage = "Failed to load image"
)
}
val inputImage = InputImage.fromBitmap(bitmap, 0)
val faces = detector.process(inputImage).await()
val croppedFace = if (faces.isNotEmpty()) {
// Crop the first detected face with some padding
cropFaceFromBitmap(bitmap, faces[0].boundingBox)
} else null
FaceDetectionResult(
uri = uri,
hasFace = faces.isNotEmpty(),
faceCount = faces.size,
faceBounds = faces.map { it.boundingBox },
croppedFaceBitmap = croppedFace
)
} catch (e: Exception) {
FaceDetectionResult(
uri = uri,
hasFace = false,
faceCount = 0,
errorMessage = e.message ?: "Unknown error"
)
}
}
/**
* Detect faces in multiple images
*/
suspend fun detectFacesInImages(uris: List<Uri>): List<FaceDetectionResult> {
return uris.map { uri ->
detectFacesInImage(uri)
}
}
/**
* Crop face from bitmap with padding
*/
private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap {
// Add 20% padding around the face
val padding = (faceBounds.width() * 0.2f).toInt()
val left = (faceBounds.left - padding).coerceAtLeast(0)
val top = (faceBounds.top - padding).coerceAtLeast(0)
val right = (faceBounds.right + padding).coerceAtMost(bitmap.width)
val bottom = (faceBounds.bottom + padding).coerceAtMost(bitmap.height)
val width = right - left
val height = bottom - top
return Bitmap.createBitmap(bitmap, left, top, width, height)
}
/**
* Load bitmap from URI
*/
private fun loadBitmap(uri: Uri): Bitmap? {
return try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)?.also {
inputStream?.close()
}
} catch (e: Exception) {
null
}
}
/**
* Clean up resources
*/
fun cleanup() {
detector.close()
}
}

View File

@@ -0,0 +1,340 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* Enhanced ImageSelectorScreen
*
* Changes:
* - NO LIMIT on photo count (was 10)
* - Recommends 20-30 photos
* - Real-time progress feedback
* - Quality indicators
* - Training tips
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageSelectorScreen(
onImagesSelected: (List<Uri>) -> Unit
) {
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) {
selectedImages = uris
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Select Training Photos") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Gradient header with tips
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.PhotoCamera,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(28.dp)
)
}
}
Column {
Text(
"Training Tips",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"More photos = better recognition",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
Spacer(Modifier.height(4.dp))
TipItem("✓ Select 20-30 photos for best results", true)
TipItem("✓ Include different angles and lighting", true)
TipItem("✓ Mix expressions (smile, neutral, laugh)", true)
TipItem("✓ With/without glasses if applicable", true)
TipItem("✗ Avoid blurry or very dark photos", false)
}
}
// Progress indicator
AnimatedVisibility(selectedImages.isNotEmpty()) {
ProgressCard(selectedImages.size)
}
Spacer(Modifier.weight(1f))
// Select photos button
Button(
onClick = { photoPicker.launch("image/*") },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(vertical = 16.dp)
) {
Icon(Icons.Default.PhotoLibrary, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
if (selectedImages.isEmpty()) {
"Select Training Photos"
} else {
"Selected: ${selectedImages.size} photos - Tap to change"
},
style = MaterialTheme.typography.titleMedium
)
}
// Continue button
AnimatedVisibility(selectedImages.size >= 15) {
Button(
onClick = { onImagesSelected(selectedImages) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
),
contentPadding = PaddingValues(vertical = 16.dp)
) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
"Continue with ${selectedImages.size} photos",
style = MaterialTheme.typography.titleMedium
)
}
}
// Minimum warning
if (selectedImages.isNotEmpty() && selectedImages.size < 15) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Column {
Text(
"Need at least 15 photos",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
"You have ${selectedImages.size}. Select ${15 - selectedImages.size} more.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
)
}
}
}
}
}
}
}
@Composable
private fun TipItem(text: String, isGood: Boolean) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
if (isGood) Icons.Default.CheckCircle else Icons.Default.Cancel,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isGood) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@Composable
private fun ProgressCard(photoCount: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primaryContainer
photoCount >= 20 -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "$photoCount photos selected",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = when {
photoCount >= 30 -> "Excellent! Maximum diversity"
photoCount >= 25 -> "Great! Very good coverage"
photoCount >= 20 -> "Good! Should work well"
photoCount >= 15 -> "Acceptable - more is better"
else -> "Need ${15 - photoCount} more"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Surface(
shape = RoundedCornerShape(12.dp),
color = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primary
photoCount >= 20 -> MaterialTheme.colorScheme.tertiary
photoCount >= 15 -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(56.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = when {
photoCount >= 25 -> ""
photoCount >= 20 -> ""
photoCount >= 15 -> ""
else -> "..."
},
style = MaterialTheme.typography.headlineMedium,
color = Color.White
)
}
}
}
// Progress bar
LinearProgressIndicator(
progress = { (photoCount / 30f).coerceAtMost(1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primary
photoCount >= 20 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.secondary
},
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
// Expected accuracy
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Expected accuracy:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
when {
photoCount >= 30 -> "90-95%"
photoCount >= 25 -> "85-90%"
photoCount >= 20 -> "80-85%"
photoCount >= 15 -> "75-80%"
else -> "< 75%"
},
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primary
photoCount >= 20 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.secondary
}
)
}
}
}
}

View File

@@ -0,0 +1,879 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScanResultsScreen(
state: ScanningState,
onFinish: () -> Unit,
trainViewModel: TrainViewModel = hiltViewModel()
) {
var showFacePickerDialog by remember { mutableStateOf<FaceDetectionHelper.FaceDetectionResult?>(null) }
var showNameInputDialog by remember { mutableStateOf(false) }
// Observe training state
val trainingState by trainViewModel.trainingState.collectAsState()
// Handle training state changes
LaunchedEffect(trainingState) {
when (trainingState) {
is TrainingState.Success -> {
// Training completed successfully
val success = trainingState as TrainingState.Success
// You can show a success message or navigate away
// For now, we'll just reset and finish
trainViewModel.resetTrainingState()
onFinish()
}
is TrainingState.Error -> {
// Error will be shown in dialog, no action needed here
}
else -> { /* Idle or Processing */ }
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Training Image Analysis") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (state) {
is ScanningState.Idle -> {
// Should not happen
}
is ScanningState.Processing -> {
ProcessingView(
progress = state.progress,
total = state.total
)
}
is ScanningState.Success -> {
ImprovedResultsView(
result = state.sanityCheckResult,
onContinue = {
// Show name input dialog instead of immediately finishing
showNameInputDialog = true
},
onRetry = onFinish,
onReplaceImage = { oldUri, newUri ->
trainViewModel.replaceImage(oldUri, newUri)
},
onSelectFaceFromMultiple = { result ->
showFacePickerDialog = result
}
)
}
is ScanningState.Error -> {
ErrorView(
message = state.message,
onRetry = onFinish
)
}
}
// Show training overlay if processing
if (trainingState is TrainingState.Processing) {
TrainingOverlay(trainingState = trainingState as TrainingState.Processing)
}
}
}
// Face Picker Dialog
showFacePickerDialog?.let { result ->
FacePickerDialog(
result = result,
onDismiss = { showFacePickerDialog = null },
onFaceSelected = { faceIndex, croppedFaceBitmap ->
trainViewModel.selectFaceFromImage(result.uri, faceIndex, croppedFaceBitmap)
showFacePickerDialog = null
}
)
}
// Name Input Dialog
if (showNameInputDialog) {
NameInputDialog(
onDismiss = { showNameInputDialog = false },
onConfirm = { name ->
showNameInputDialog = false
trainViewModel.createFaceModel(name)
},
trainingState = trainingState
)
}
}
/**
* Dialog for entering person's name before training
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NameInputDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
trainingState: TrainingState
) {
var personName by remember { mutableStateOf("") }
val isError = trainingState is TrainingState.Error
AlertDialog(
onDismissRequest = {
if (trainingState !is TrainingState.Processing) {
onDismiss()
}
},
title = {
Text(
text = if (isError) "Training Error" else "Who is this?",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
if (isError) {
// Show error message
val error = trainingState as TrainingState.Error
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Text(
text = error.message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
} else {
Text(
text = "Enter the name of the person in these training images. This will help you find their photos later.",
style = MaterialTheme.typography.bodyMedium
)
}
OutlinedTextField(
value = personName,
onValueChange = { personName = it },
label = { Text("Person's Name") },
placeholder = { Text("e.g., John Doe") },
singleLine = true,
enabled = trainingState !is TrainingState.Processing,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (personName.isNotBlank()) {
onConfirm(personName.trim())
}
}
),
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(personName.trim()) },
enabled = personName.isNotBlank() && trainingState !is TrainingState.Processing
) {
if (trainingState is TrainingState.Processing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(if (isError) "Try Again" else "Start Training")
}
},
dismissButton = {
if (trainingState !is TrainingState.Processing) {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
}
)
}
/**
* Overlay shown during training process
*/
@Composable
private fun TrainingOverlay(trainingState: TrainingState.Processing) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.7f)),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.padding(32.dp)
.fillMaxWidth(0.9f),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
strokeWidth = 6.dp
)
Text(
text = "Creating Face Model",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = trainingState.stage,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (trainingState.total > 0) {
LinearProgressIndicator(
progress = { (trainingState.progress.toFloat() / trainingState.total.toFloat()).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth()
)
Text(
text = "${trainingState.progress} / ${trainingState.total}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@Composable
private fun ProcessingView(progress: Int, total: Int) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(64.dp),
strokeWidth = 6.dp
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Analyzing images...",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Detecting faces and checking for duplicates",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (total > 0) {
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { (progress.toFloat() / total.toFloat()).coerceIn(0f, 1f) },
modifier = Modifier.width(200.dp)
)
Text(
text = "$progress / $total",
style = MaterialTheme.typography.bodySmall
)
}
}
}
@Composable
private fun ImprovedResultsView(
result: TrainingSanityChecker.SanityCheckResult,
onContinue: () -> Unit,
onRetry: () -> Unit,
onReplaceImage: (Uri, Uri) -> Unit,
onSelectFaceFromMultiple: (FaceDetectionHelper.FaceDetectionResult) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Welcome Header
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "Analysis Complete!",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Review your images below. Tap 'Pick Face' on group photos to choose which person to train on, or 'Replace' to swap out any image.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
)
}
}
}
// Progress Summary
item {
ProgressSummaryCard(
totalImages = result.faceDetectionResults.size,
validImages = result.validImagesWithFaces.size,
requiredImages = 10,
isValid = result.isValid
)
}
// Image List Header
item {
Text(
text = "Your Images (${result.faceDetectionResults.size})",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
// Image List with Actions
itemsIndexed(result.faceDetectionResults) { index, imageResult ->
ImageResultCard(
index = index + 1,
result = imageResult,
onReplace = { newUri ->
onReplaceImage(imageResult.uri, newUri)
},
onSelectFace = if (imageResult.faceCount > 1) {
{ onSelectFaceFromMultiple(imageResult) }
} else null
)
}
// Validation Issues (if any)
if (result.validationErrors.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
ValidationIssuesCard(errors = result.validationErrors)
}
}
// Action Button
item {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = if (result.isValid) onContinue else onRetry,
modifier = Modifier.fillMaxWidth(),
enabled = result.isValid,
colors = ButtonDefaults.buttonColors(
containerColor = if (result.isValid)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error.copy(alpha = 0.5f)
)
) {
Icon(
if (result.isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
if (result.isValid)
"Continue to Training (${result.validImagesWithFaces.size} images)"
else
"Fix ${result.validationErrors.size} Issue${if (result.validationErrors.size != 1) "s" else ""} to Continue"
)
}
if (!result.isValid) {
Spacer(modifier = Modifier.height(8.dp))
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Tip: Use 'Replace' to swap problematic images, or 'Pick Face' to choose from group photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
}
}
}
@Composable
private fun ProgressSummaryCard(
totalImages: Int,
validImages: Int,
requiredImages: Int,
isValid: Boolean
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isValid)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Progress",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = if (isValid) Icons.Default.CheckCircle else Icons.Default.Warning,
contentDescription = null,
tint = if (isValid)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
label = "Total",
value = totalImages.toString(),
color = MaterialTheme.colorScheme.onSurface
)
StatItem(
label = "Valid",
value = validImages.toString(),
color = if (validImages >= requiredImages)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.error
)
StatItem(
label = "Need",
value = requiredImages.toString(),
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.height(12.dp))
LinearProgressIndicator(
progress = { (validImages.toFloat() / requiredImages.toFloat()).coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
color = if (isValid) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
)
}
}
}
@Composable
private fun StatItem(label: String, value: String, color: Color) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = color.copy(alpha = 0.7f)
)
}
}
@Composable
private fun ImageResultCard(
index: Int,
result: FaceDetectionHelper.FaceDetectionResult,
onReplace: (Uri) -> Unit,
onSelectFace: (() -> Unit)?
) {
val photoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
uri?.let { onReplace(it) }
}
val status = when {
result.errorMessage != null -> ImageStatus.ERROR
!result.hasFace -> ImageStatus.NO_FACE
result.faceCount > 1 -> ImageStatus.MULTIPLE_FACES
result.faceCount == 1 -> ImageStatus.VALID
else -> ImageStatus.ERROR
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when (status) {
ImageStatus.VALID -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)
else -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Image Number Badge
Box(
modifier = Modifier
.size(40.dp)
.background(
color = when (status) {
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
},
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Text(
text = index.toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
// Thumbnail
if (result.croppedFaceBitmap != null) {
Image(
bitmap = result.croppedFaceBitmap.asImageBitmap(),
contentDescription = "Face",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.border(
BorderStroke(
2.dp,
when (status) {
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
),
RoundedCornerShape(8.dp)
),
contentScale = ContentScale.Crop
)
} else {
AsyncImage(
model = result.uri,
contentDescription = "Original image",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
// Status and Info
Column(
modifier = Modifier.weight(1f)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = when (status) {
ImageStatus.VALID -> Icons.Default.CheckCircle
ImageStatus.MULTIPLE_FACES -> Icons.Default.Info
else -> Icons.Default.Warning
},
contentDescription = null,
tint = when (status) {
ImageStatus.VALID -> MaterialTheme.colorScheme.primary
ImageStatus.MULTIPLE_FACES -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
},
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = when (status) {
ImageStatus.VALID -> "Face Detected"
ImageStatus.MULTIPLE_FACES -> "Multiple Faces (${result.faceCount})"
ImageStatus.NO_FACE -> "No Face Detected"
ImageStatus.ERROR -> "Error"
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
Text(
text = result.uri.lastPathSegment ?: "Unknown",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
// Action Buttons
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Select Face button (for multiple faces)
if (onSelectFace != null) {
OutlinedButton(
onClick = onSelectFace,
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.tertiary
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.tertiary)
) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Pick Face", style = MaterialTheme.typography.bodySmall)
}
}
// Replace button
OutlinedButton(
onClick = {
photoPickerLauncher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
)
},
modifier = Modifier.height(32.dp),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp)
) {
Icon(
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("Replace", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
@Composable
private fun ValidationIssuesCard(errors: List<TrainingSanityChecker.ValidationError>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Issues Found (${errors.size})",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
HorizontalDivider(color = MaterialTheme.colorScheme.error.copy(alpha = 0.3f))
errors.forEach { error ->
when (error) {
is TrainingSanityChecker.ValidationError.NoFaceDetected -> {
Text(
text = "${error.uris.size} image(s) without detected faces - use Replace button",
style = MaterialTheme.typography.bodyMedium
)
}
is TrainingSanityChecker.ValidationError.MultipleFacesDetected -> {
Text(
text = "${error.uri.lastPathSegment} has ${error.faceCount} faces - use Pick Face button",
style = MaterialTheme.typography.bodyMedium
)
}
is TrainingSanityChecker.ValidationError.DuplicateImages -> {
Text(
text = "${error.groups.size} duplicate image group(s) - replace duplicates",
style = MaterialTheme.typography.bodyMedium
)
}
is TrainingSanityChecker.ValidationError.InsufficientImages -> {
Text(
text = "• Need ${error.required} valid images, currently have ${error.available}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
}
is TrainingSanityChecker.ValidationError.ImageLoadError -> {
Text(
text = "• Failed to load ${error.uri.lastPathSegment} - use Replace button",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
@Composable
private fun ErrorView(
message: String,
onRetry: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Error",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Try Again")
}
}
}
private enum class ImageStatus {
VALID,
MULTIPLE_FACES,
NO_FACE,
ERROR
}

View File

@@ -0,0 +1,316 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.app.Application
import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.ml.FaceNetModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class ScanningState {
object Idle : ScanningState()
data class Processing(val progress: Int, val total: Int) : ScanningState()
data class Success(
val sanityCheckResult: TrainingSanityChecker.SanityCheckResult
) : ScanningState()
data class Error(val message: String) : ScanningState()
}
sealed class TrainingState {
object Idle : TrainingState()
data class Processing(val stage: String, val progress: Int, val total: Int) : TrainingState()
data class Success(
val personName: String,
val personId: String,
val relationship: String?
) : TrainingState()
data class Error(val message: String) : TrainingState()
}
/**
* Person info captured before photo selection
*/
data class PersonInfo(
val name: String,
val dateOfBirth: Long?,
val relationship: String
)
@HiltViewModel
class TrainViewModel @Inject constructor(
application: Application,
private val faceRecognitionRepository: FaceRecognitionRepository,
private val faceNetModel: FaceNetModel
) : AndroidViewModel(application) {
private val sanityChecker = TrainingSanityChecker(application)
private val faceDetectionHelper = FaceDetectionHelper(application)
private val _uiState = MutableStateFlow<ScanningState>(ScanningState.Idle)
val uiState: StateFlow<ScanningState> = _uiState.asStateFlow()
private val _trainingState = MutableStateFlow<TrainingState>(TrainingState.Idle)
val trainingState: StateFlow<TrainingState> = _trainingState.asStateFlow()
// Store person info for later use during training
private var personInfo: PersonInfo? = null
private var currentImageUris: List<Uri> = emptyList()
private val manualFaceSelections = mutableMapOf<Uri, ManualFaceSelection>()
data class ManualFaceSelection(
val faceIndex: Int,
val croppedFaceBitmap: Bitmap
)
/**
* Store person info before photo selection
*/
fun setPersonInfo(name: String, dateOfBirth: Long?, relationship: String) {
personInfo = PersonInfo(name, dateOfBirth, relationship)
}
/**
* Create face model with captured person info
*/
fun createFaceModel(personName: String) {
val currentState = _uiState.value
if (currentState !is ScanningState.Success) {
_trainingState.value = TrainingState.Error("No validated images available")
return
}
val validImages = currentState.sanityCheckResult.validImagesWithFaces
if (validImages.size < 15) { // Updated minimum
_trainingState.value = TrainingState.Error(
"Need at least 15 valid images, have ${validImages.size}"
)
return
}
viewModelScope.launch {
try {
_trainingState.value = TrainingState.Processing(
stage = "Creating person and training model",
progress = 0,
total = validImages.size
)
// Create person with captured info
val person = PersonEntity.create(
name = personName,
dateOfBirth = personInfo?.dateOfBirth,
relationship = personInfo?.relationship
)
// Create person with face model
val personId = faceRecognitionRepository.createPersonWithFaceModel(
person = person, // Pass full PersonEntity now
validImages = validImages,
onProgress = { current, total ->
_trainingState.value = TrainingState.Processing(
stage = "Processing image $current/$total",
progress = current,
total = total
)
}
)
_trainingState.value = TrainingState.Success(
personName = personName,
personId = personId,
relationship = person.relationship
)
} catch (e: Exception) {
_trainingState.value = TrainingState.Error(
e.message ?: "Failed to create face model"
)
}
}
}
fun resetTrainingState() {
_trainingState.value = TrainingState.Idle
}
fun scanAndTagFaces(imageUris: List<Uri>) {
currentImageUris = imageUris
manualFaceSelections.clear()
performScan(imageUris)
}
fun replaceImage(oldUri: Uri, newUri: Uri) {
viewModelScope.launch {
val updatedUris = currentImageUris.toMutableList()
val index = updatedUris.indexOf(oldUri)
if (index != -1) {
updatedUris[index] = newUri
currentImageUris = updatedUris
manualFaceSelections.remove(oldUri)
performScan(currentImageUris)
}
}
}
fun selectFaceFromImage(imageUri: Uri, faceIndex: Int, croppedFaceBitmap: Bitmap) {
manualFaceSelections[imageUri] = ManualFaceSelection(faceIndex, croppedFaceBitmap)
val currentState = _uiState.value
if (currentState is ScanningState.Success) {
val updatedResult = applyManualSelections(currentState.sanityCheckResult)
_uiState.value = ScanningState.Success(updatedResult)
}
}
private fun performScan(imageUris: List<Uri>) {
viewModelScope.launch {
try {
_uiState.value = ScanningState.Processing(0, imageUris.size)
val result = sanityChecker.performSanityChecks(
imageUris = imageUris,
minImagesRequired = 15, // Updated minimum
allowMultipleFaces = true,
duplicateSimilarityThreshold = 0.95
)
val finalResult = applyManualSelections(result)
_uiState.value = ScanningState.Success(finalResult)
} catch (e: Exception) {
_uiState.value = ScanningState.Error(
e.message ?: "An unknown error occurred"
)
}
}
}
private fun applyManualSelections(
result: TrainingSanityChecker.SanityCheckResult
): TrainingSanityChecker.SanityCheckResult {
if (manualFaceSelections.isEmpty()) {
return result
}
val updatedFaceResults = result.faceDetectionResults.map { faceResult ->
val manualSelection = manualFaceSelections[faceResult.uri]
if (manualSelection != null) {
faceResult.copy(
croppedFaceBitmap = manualSelection.croppedFaceBitmap,
faceCount = 1
)
} else {
faceResult
}
}
val updatedValidImages = updatedFaceResults
.filter { it.hasFace }
.filter { it.croppedFaceBitmap != null }
.filter { it.errorMessage == null }
.filter { it.faceCount >= 1 }
.map { result ->
TrainingSanityChecker.ValidTrainingImage(
uri = result.uri,
croppedFaceBitmap = result.croppedFaceBitmap!!,
faceCount = result.faceCount
)
}
val updatedErrors = result.validationErrors.toMutableList()
updatedErrors.removeAll { error ->
error is TrainingSanityChecker.ValidationError.MultipleFacesDetected &&
manualFaceSelections.containsKey(error.uri)
}
if (updatedValidImages.size < 15) { // Updated minimum
if (updatedErrors.none { it is TrainingSanityChecker.ValidationError.InsufficientImages }) {
updatedErrors.add(
TrainingSanityChecker.ValidationError.InsufficientImages(
required = 15,
available = updatedValidImages.size
)
)
}
} else {
updatedErrors.removeAll { it is TrainingSanityChecker.ValidationError.InsufficientImages }
}
val isValid = updatedErrors.isEmpty() && updatedValidImages.size >= 15
return result.copy(
isValid = isValid,
faceDetectionResults = updatedFaceResults,
validationErrors = updatedErrors,
validImagesWithFaces = updatedValidImages
)
}
fun getFormattedErrors(result: TrainingSanityChecker.SanityCheckResult): List<String> {
return sanityChecker.formatValidationErrors(result.validationErrors)
}
fun reset() {
_uiState.value = ScanningState.Idle
_trainingState.value = TrainingState.Idle
currentImageUris = emptyList()
manualFaceSelections.clear()
personInfo = null
}
override fun onCleared() {
super.onCleared()
sanityChecker.cleanup()
faceDetectionHelper.cleanup()
faceNetModel.close()
}
}
// Extension functions for copying results
private fun FaceDetectionHelper.FaceDetectionResult.copy(
uri: Uri = this.uri,
hasFace: Boolean = this.hasFace,
faceCount: Int = this.faceCount,
faceBounds: List<android.graphics.Rect> = this.faceBounds,
croppedFaceBitmap: Bitmap? = this.croppedFaceBitmap,
errorMessage: String? = this.errorMessage
): FaceDetectionHelper.FaceDetectionResult {
return FaceDetectionHelper.FaceDetectionResult(
uri = uri,
hasFace = hasFace,
faceCount = faceCount,
faceBounds = faceBounds,
croppedFaceBitmap = croppedFaceBitmap,
errorMessage = errorMessage
)
}
private fun TrainingSanityChecker.SanityCheckResult.copy(
isValid: Boolean = this.isValid,
faceDetectionResults: List<FaceDetectionHelper.FaceDetectionResult> = this.faceDetectionResults,
duplicateCheckResult: DuplicateImageDetector.DuplicateCheckResult = this.duplicateCheckResult,
validationErrors: List<TrainingSanityChecker.ValidationError> = this.validationErrors,
warnings: List<String> = this.warnings,
validImagesWithFaces: List<TrainingSanityChecker.ValidTrainingImage> = this.validImagesWithFaces
): TrainingSanityChecker.SanityCheckResult {
return TrainingSanityChecker.SanityCheckResult(
isValid = isValid,
faceDetectionResults = faceDetectionResults,
duplicateCheckResult = duplicateCheckResult,
validationErrors = validationErrors,
warnings = warnings,
validImagesWithFaces = validImagesWithFaces
)
}

View File

@@ -0,0 +1,516 @@
package com.placeholder.sherpai2.ui.trainingprep
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import java.text.SimpleDateFormat
import java.util.*
/**
* Beautiful TrainingScreen with person info capture
*
* Features:
* - Name input
* - Date of birth picker
* - Relationship selector
* - Onboarding cards
* - Beautiful gradient design
* - Clear call to action
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrainingScreen(
onSelectImages: () -> Unit,
modifier: Modifier = Modifier
) {
var showInfoDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Train New Person") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
// Hero section with gradient
HeroCard()
// How it works section
HowItWorksSection()
// Requirements section
RequirementsCard()
Spacer(Modifier.weight(1f))
// Main CTA button
Button(
onClick = { showInfoDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(16.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(12.dp))
Text(
"Start Training",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(8.dp))
}
}
// Person info dialog
if (showInfoDialog) {
PersonInfoDialog(
onDismiss = { showInfoDialog = false },
onConfirm = { name, dob, relationship ->
showInfoDialog = false
// TODO: Store this info before photo selection
// For now, just proceed to photo selection
onSelectImages()
}
)
}
}
@Composable
private fun HeroCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
)
)
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.primary,
shadowElevation = 8.dp,
modifier = Modifier.size(80.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Text(
"Face Recognition Training",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
"Train the AI to recognize someone in your photos",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
}
@Composable
private fun HowItWorksSection() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"How It Works",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
StepCard(
number = 1,
icon = Icons.Default.Info,
title = "Enter Person Details",
description = "Name, birthday, and relationship"
)
StepCard(
number = 2,
icon = Icons.Default.PhotoLibrary,
title = "Select Training Photos",
description = "Choose 20-30 photos of the person"
)
StepCard(
number = 3,
icon = Icons.Default.ModelTraining,
title = "AI Learns Their Face",
description = "Takes ~30 seconds to train"
)
StepCard(
number = 4,
icon = Icons.Default.Search,
title = "Auto-Tag Your Library",
description = "Find them in all your photos"
)
}
}
@Composable
private fun StepCard(
number: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
description: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Number badge
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = number.toString(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(4.dp))
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun RequirementsCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"What You'll Need",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
RequirementItem("20-30 photos of the person", true)
RequirementItem("Different angles and lighting", true)
RequirementItem("Clear face visibility", true)
RequirementItem("Mix of expressions", true)
RequirementItem("2-3 minutes of your time", true)
}
}
}
@Composable
private fun RequirementItem(text: String, isMet: Boolean) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (isMet) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isMet) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PersonInfoDialog(
onDismiss: () -> Unit,
onConfirm: (name: String, dateOfBirth: Long?, relationship: String) -> Unit
) {
var name by remember { mutableStateOf("") }
var dateOfBirth by remember { mutableStateOf<Long?>(null) }
var selectedRelationship by remember { mutableStateOf("Other") }
var showDatePicker by remember { mutableStateOf(false) }
val relationships = listOf(
"Family" to "👨‍👩‍👧‍👦",
"Friend" to "🤝",
"Partner" to "❤️",
"Child" to "👶",
"Parent" to "👪",
"Sibling" to "👫",
"Colleague" to "💼",
"Other" to "👤"
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text("Person Details")
Text(
"Help us organize your photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name *") },
placeholder = { Text("e.g., John Doe") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Date of birth
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Cake, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
if (dateOfBirth != null) {
"Birthday: ${formatDate(dateOfBirth!!)}"
} else {
"Add Birthday (Optional)"
}
)
}
// Relationship selector
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Relationship",
style = MaterialTheme.typography.labelMedium
)
// Relationship chips
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
relationships.take(4).forEach { (rel, emoji) ->
FilterChip(
selected = selectedRelationship == rel,
onClick = { selectedRelationship = rel },
label = { Text("$emoji $rel") }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
relationships.drop(4).forEach { (rel, emoji) ->
FilterChip(
selected = selectedRelationship == rel,
onClick = { selectedRelationship = rel },
label = { Text("$emoji $rel") }
)
}
}
}
// Privacy note
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"All data stays on your device",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(name, dateOfBirth, selectedRelationship)
}
},
enabled = name.isNotBlank()
) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
// Date picker dialog
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
// Get selected date from date picker
// For now, set to current date as placeholder
dateOfBirth = System.currentTimeMillis()
showDatePicker = false
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("Cancel")
}
}
) {
// Material3 DatePicker
DatePicker(
state = rememberDatePickerState(),
modifier = Modifier.padding(16.dp)
)
}
}
}
private fun formatDate(timestamp: Long): String {
val formatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return formatter.format(Date(timestamp))
}

View File

@@ -0,0 +1,188 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
/**
* Coordinates sanity checks for training images
*/
class TrainingSanityChecker(private val context: Context) {
private val faceDetectionHelper = FaceDetectionHelper(context)
private val duplicateDetector = DuplicateImageDetector(context)
data class SanityCheckResult(
val isValid: Boolean,
val faceDetectionResults: List<FaceDetectionHelper.FaceDetectionResult>,
val duplicateCheckResult: DuplicateImageDetector.DuplicateCheckResult,
val validationErrors: List<ValidationError>,
val warnings: List<String>,
val validImagesWithFaces: List<ValidTrainingImage>
)
data class ValidTrainingImage(
val uri: Uri,
val croppedFaceBitmap: Bitmap,
val faceCount: Int
)
sealed class ValidationError {
data class NoFaceDetected(val uris: List<Uri>) : ValidationError()
data class MultipleFacesDetected(val uri: Uri, val faceCount: Int) : ValidationError()
data class DuplicateImages(val groups: List<DuplicateImageDetector.DuplicateGroup>) : ValidationError()
data class InsufficientImages(val required: Int, val available: Int) : ValidationError()
data class ImageLoadError(val uri: Uri, val error: String) : ValidationError()
}
/**
* Perform comprehensive sanity checks on training images
*/
suspend fun performSanityChecks(
imageUris: List<Uri>,
minImagesRequired: Int = 10,
allowMultipleFaces: Boolean = false,
duplicateSimilarityThreshold: Double = 0.95
): SanityCheckResult {
val validationErrors = mutableListOf<ValidationError>()
val warnings = mutableListOf<String>()
// Check minimum image count
if (imageUris.size < minImagesRequired) {
validationErrors.add(
ValidationError.InsufficientImages(
required = minImagesRequired,
available = imageUris.size
)
)
}
// Step 1: Detect faces in all images
val faceDetectionResults = faceDetectionHelper.detectFacesInImages(imageUris)
// Check for images without faces
val imagesWithoutFaces = faceDetectionResults.filter { !it.hasFace }
if (imagesWithoutFaces.isNotEmpty()) {
validationErrors.add(
ValidationError.NoFaceDetected(
uris = imagesWithoutFaces.map { it.uri }
)
)
}
// Check for images with errors
faceDetectionResults.filter { it.errorMessage != null }.forEach { result ->
validationErrors.add(
ValidationError.ImageLoadError(
uri = result.uri,
error = result.errorMessage ?: "Unknown error"
)
)
}
// Check for images with multiple faces
if (!allowMultipleFaces) {
faceDetectionResults.filter { it.faceCount > 1 }.forEach { result ->
validationErrors.add(
ValidationError.MultipleFacesDetected(
uri = result.uri,
faceCount = result.faceCount
)
)
}
} else {
faceDetectionResults.filter { it.faceCount > 1 }.forEach { result ->
warnings.add("Image ${result.uri.lastPathSegment} contains ${result.faceCount} faces. Using the largest detected face.")
}
}
// Step 2: Check for duplicate images
val duplicateCheckResult = duplicateDetector.checkForDuplicates(
uris = imageUris,
similarityThreshold = duplicateSimilarityThreshold
)
if (duplicateCheckResult.hasDuplicates) {
validationErrors.add(
ValidationError.DuplicateImages(
groups = duplicateCheckResult.duplicateGroups
)
)
}
// Step 3: Create list of valid training images
val validImagesWithFaces = faceDetectionResults
.filter { it.hasFace && it.croppedFaceBitmap != null }
.filter { allowMultipleFaces || it.faceCount == 1 }
.map { result ->
ValidTrainingImage(
uri = result.uri,
croppedFaceBitmap = result.croppedFaceBitmap!!,
faceCount = result.faceCount
)
}
// Check if we have enough valid images after all checks
if (validImagesWithFaces.size < minImagesRequired) {
val existingError = validationErrors.find { it is ValidationError.InsufficientImages }
if (existingError == null) {
validationErrors.add(
ValidationError.InsufficientImages(
required = minImagesRequired,
available = validImagesWithFaces.size
)
)
}
}
val isValid = validationErrors.isEmpty() && validImagesWithFaces.size >= minImagesRequired
return SanityCheckResult(
isValid = isValid,
faceDetectionResults = faceDetectionResults,
duplicateCheckResult = duplicateCheckResult,
validationErrors = validationErrors,
warnings = warnings,
validImagesWithFaces = validImagesWithFaces
)
}
/**
* Format validation errors into human-readable messages
*/
fun formatValidationErrors(errors: List<ValidationError>): List<String> {
return errors.map { error ->
when (error) {
is ValidationError.NoFaceDetected -> {
val count = error.uris.size
val images = error.uris.joinToString(", ") { it.lastPathSegment ?: "Unknown" }
"No face detected in $count image(s): $images"
}
is ValidationError.MultipleFacesDetected -> {
"Multiple faces (${error.faceCount}) detected in: ${error.uri.lastPathSegment}"
}
is ValidationError.DuplicateImages -> {
val count = error.groups.size
val details = error.groups.joinToString("\n") { group ->
" - ${group.images.size} duplicates: ${group.images.joinToString(", ") { it.lastPathSegment ?: "Unknown" }}"
}
"Found $count duplicate group(s):\n$details"
}
is ValidationError.InsufficientImages -> {
"Insufficient images: need ${error.required}, but only ${error.available} valid images available"
}
is ValidationError.ImageLoadError -> {
"Failed to load image ${error.uri.lastPathSegment}: ${error.error}"
}
}
}
}
/**
* Clean up resources
*/
fun cleanup() {
faceDetectionHelper.cleanup()
}
}

View File

@@ -0,0 +1,78 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ViewModel for managing training image sanity checks
*/
class TrainingSanityViewModel(application: Application) : AndroidViewModel(application) {
private val sanityChecker = TrainingSanityChecker(application)
private val _uiState = MutableStateFlow<TrainingSanityUiState>(TrainingSanityUiState.Idle)
val uiState: StateFlow<TrainingSanityUiState> = _uiState.asStateFlow()
sealed class TrainingSanityUiState {
object Idle : TrainingSanityUiState()
object Checking : TrainingSanityUiState()
data class Success(
val result: TrainingSanityChecker.SanityCheckResult
) : TrainingSanityUiState()
data class Error(val message: String) : TrainingSanityUiState()
}
/**
* Perform sanity checks on selected images
*/
fun checkImages(
imageUris: List<Uri>,
minImagesRequired: Int = 10,
allowMultipleFaces: Boolean = false,
duplicateSimilarityThreshold: Double = 0.95
) {
viewModelScope.launch {
try {
_uiState.value = TrainingSanityUiState.Checking
val result = sanityChecker.performSanityChecks(
imageUris = imageUris,
minImagesRequired = minImagesRequired,
allowMultipleFaces = allowMultipleFaces,
duplicateSimilarityThreshold = duplicateSimilarityThreshold
)
_uiState.value = TrainingSanityUiState.Success(result)
} catch (e: Exception) {
_uiState.value = TrainingSanityUiState.Error(
e.message ?: "An unknown error occurred during sanity checks"
)
}
}
}
/**
* Reset the UI state
*/
fun resetState() {
_uiState.value = TrainingSanityUiState.Idle
}
/**
* Get formatted error messages from validation result
*/
fun getFormattedErrors(result: TrainingSanityChecker.SanityCheckResult): List<String> {
return sanityChecker.formatValidationErrors(result.validationErrors)
}
override fun onCleared() {
super.onCleared()
sanityChecker.cleanup()
}
}

View File

@@ -0,0 +1,68 @@
package com.placeholder.sherpai2.util
/**
* Debug feature flags
*
* Toggle these to enable/disable diagnostic features
* Set to false before release builds!
*/
object DebugFlags {
/**
* Enable verbose face recognition logging
*
* When true:
* - Logs every face detection
* - Logs similarity scores
* - Logs matching decisions
* - Shows why images are skipped
*
* Filter Logcat by: "FaceRecognition"
*/
const val ENABLE_FACE_RECOGNITION_LOGGING = true // ← Toggle here
/**
* Show confidence scores in UI
*/
const val SHOW_CONFIDENCE_IN_UI = true // ← Toggle here
/**
* Lower thresholds for better recall (more matches, some false positives)
*/
const val USE_LIBERAL_THRESHOLDS = true // ← Toggle here
}
/**
* Diagnostic logger - only logs when flag is enabled
*/
object DiagnosticLogger {
private const val TAG = "FaceRecognition"
fun d(message: String) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
android.util.Log.d(TAG, message)
}
}
fun i(message: String) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
android.util.Log.i(TAG, message)
}
}
fun w(message: String) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
android.util.Log.w(TAG, message)
}
}
fun e(message: String, throwable: Throwable? = null) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
if (throwable != null) {
android.util.Log.e(TAG, message, throwable)
} else {
android.util.Log.e(TAG, message)
}
}
}
}

View File

@@ -3,4 +3,10 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
//Adding these two fixes the conflicts between hilt / room / ksp and javbapoet (cannonicalName())
//https://github.com/google/dagger/issues/4048#issuecomment-1864237679
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt.android) apply false
} }

View File

@@ -20,4 +20,5 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
org.gradle.java.home=/snap/android-studio/current/jbr

View File

@@ -1,32 +1,77 @@
[versions] [versions]
agp = "8.13.1" # Tooling
agp = "8.13.2"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.17.0" ksp = "2.0.21-1.0.28"
junit = "4.13.2"
junitVersion = "1.3.0" # AndroidX / Lifecycle
espressoCore = "3.7.0" coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.10.0" lifecycle = "2.8.7"
activityCompose = "1.12.1" activityCompose = "1.9.3"
composeBom = "2024.09.00" composeBom = "2025.12.01"
navigationCompose = "2.8.5"
hiltNavigationCompose = "1.3.0"
# DI & Database
hilt = "2.57.2"
room = "2.8.4"
# Images
coil = "2.7.0"
# Face Detect
mlkit-face-detection = "16.1.6"
coroutines-play-services = "1.8.1"
# Models
tensorflow-lite = "2.14.0"
tensorflow-lite-support = "0.4.4"
gson = "2.10.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
# Compose
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
# Navigation & Hilt
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Misc
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
#Face Detect
mlkit-face-detection = { group = "com.google.mlkit", name = "face-detection", version.ref = "mlkit-face-detection"}
kotlinx-coroutines-play-services = {group = "org.jetbrains.kotlinx",name = "kotlinx-coroutines-play-services",version.ref = "coroutines-play-services"}
# TensorFlow Lite for FaceNet
tensorflow-lite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tensorflow-lite" }
tensorflow-lite-support = { group = "org.tensorflow", name = "tensorflow-lite-support", version.ref = "tensorflow-lite-support" }
tensorflow-lite-gpu = { group = "org.tensorflow", name = "tensorflow-lite-gpu", version.ref = "tensorflow-lite" }
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

View File

@@ -1,6 +1,6 @@
#Tue Dec 09 23:28:28 EST 2025 #Tue Dec 09 23:28:28 EST 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists