10 Commits

Author SHA1 Message Date
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
69 changed files with 4241 additions and 674 deletions

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
SherpAI2

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>

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>

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

View File

@@ -1,77 +1,81 @@
// 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") // Coil Images
debugImplementation("androidx.compose.ui:ui-test-manifest") implementation(libs.coil.compose)
// ML Kit
implementation(libs.mlkit.face.detection)
implementation(libs.kotlinx.coroutines.play.services)
implementation("androidx.compose.foundation:foundation:1.6.0") // Use your current Compose version
implementation("androidx.compose.material3:material3:1.2.1") // <-- Fix/Reconfirm Material 3
implementation("io.coil-kt:coil-compose:2.6.0")
} }

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,30 +1,86 @@
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.Text
import androidx.compose.material3.Surface 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 kotlinx.coroutines.launch
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)
// Determine storage permission based on Android version
val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
@Suppress("DEPRECATION")
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, storagePermission) ==
modifier = Modifier.fillMaxSize(), PackageManager.PERMISSION_GRANTED
color = MaterialTheme.colorScheme.background )
) { }
// Launch the main navigation UI
// Track ingestion completion
var imagesIngested by remember { mutableStateOf(false) }
// Launcher for permission request
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
hasPermission = granted
}
// Trigger ingestion once permission is granted
LaunchedEffect(hasPermission) {
if (hasPermission) {
// Suspend until ingestion completes
imageRepository.ingestImages()
imagesIngested = true
} else {
permissionLauncher.launch(storagePermission)
}
}
// Gate UI until permission granted AND ingestion completed
if (hasPermission && imagesIngested) {
MainScreen() MainScreen()
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
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,47 @@
package com.placeholder.sherpai2.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
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.dao.ImagePersonDao
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.EventEntity
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity
import com.placeholder.sherpai2.data.local.entity.ImagePersonEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
@Database(
entities = [
ImageEntity::class,
TagEntity::class,
PersonEntity::class,
EventEntity::class,
ImageTagEntity::class,
ImagePersonEntity::class,
ImageEventEntity::class
],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun imageDao(): ImageDao
abstract fun tagDao(): TagDao
abstract fun personDao(): PersonDao
abstract fun eventDao(): EventDao
abstract fun imageTagDao(): ImageTagDao
abstract fun imagePersonDao(): ImagePersonDao
abstract fun imageEventDao(): ImageEventDao
abstract fun imageAggregateDao(): ImageAggregateDao
}

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

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,25 @@
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.ImagePersonEntity
@Dao
interface ImagePersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ImagePersonEntity)
/**
* All images containing a specific person.
*/
@Query("""
SELECT imageId FROM image_persons
WHERE personId = :personId
AND visibility = 'PUBLIC'
AND confirmed = 1
""")
suspend fun findImagesForPerson(personId: String): List<String>
}

View File

@@ -0,0 +1,53 @@
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)
/**
* Observe tags for an image.
*/
@Query("""
SELECT * FROM image_tags
WHERE imageId = :imageId
AND visibility != 'HIDDEN'
""")
fun observeTagsForImage(imageId: String): Flow<List<ImageTagEntity>>
/**
* Find images by tag.
*
* This is your primary tag-search query.
*/
@Query("""
SELECT imageId FROM image_tags
WHERE tagId = :tagId
AND visibility = 'PUBLIC'
AND confidence >= :minConfidence
""")
suspend fun findImagesByTag(
tagId: String,
minConfidence: Float = 0.5f
): 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,24 @@
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.PersonEntity
@Dao
interface PersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(person: PersonEntity)
@Query("SELECT * FROM persons WHERE personId = :personId")
suspend fun getById(personId: String): PersonEntity?
@Query("""
SELECT * FROM persons
WHERE isHidden = 0
ORDER BY displayName
""")
suspend fun getVisiblePeople(): List<PersonEntity>
}

View File

@@ -0,0 +1,24 @@
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.TagEntity
@Dao
interface TagDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity)
/**
* Resolve a tag by value.
* Example: "park"
*/
@Query("SELECT * FROM tags WHERE value = :value LIMIT 1")
suspend fun getByValue(value: String): TagEntity?
@Query("SELECT * FROM tags")
suspend fun getAll(): List<TagEntity>
}

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,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,40 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "image_persons",
primaryKeys = ["imageId", "personId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["personId"],
childColumns = ["personId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("personId")
]
)
data class ImagePersonEntity(
val imageId: String,
val personId: String,
val confidence: Float,
val confirmed: Boolean,
/**
* PUBLIC | PRIVATE
*/
val visibility: String
)

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,30 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a known person.
*
* People are separate from generic tags because:
* - face embeddings
* - privacy rules
* - identity merging
*/
@Entity(tableName = "persons")
data class PersonEntity(
@PrimaryKey
val personId: String,
val displayName: String,
/**
* Reference to face embedding storage (ML layer).
*/
val faceEmbeddingId: String?,
val isHidden: Boolean,
val createdAt: Long
)

View File

@@ -0,0 +1,30 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents a conceptual tag.
*
* Tags are normalized so that:
* - "park" exists once
* - many images can reference it
*/
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey
val tagId: String,
/**
* GENERIC | SYSTEM | HIDDEN
*/
val type: String,
/**
* Human-readable value, e.g. "park", "sunset"
*/
val value: String,
val createdAt: Long
)

View File

@@ -0,0 +1,29 @@
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 persons: List<ImagePersonEntity>,
@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

@@ -1,20 +0,0 @@
package com.placeholder.sherpai2.data.photos
data class Photo(
val id: String,
val uri: String,
val title: String? = null,
val timestamp: Long
)
data class Album(
val id: String,
val title: String,
val photos: List<Photo>
)
data class AlbumPhoto(
val id: Int,
val imageUrl: String,
val description: String
)

View File

@@ -1,25 +0,0 @@
package com.placeholder.sherpai2.data.photos
import com.placeholder.sherpai2.data.photos.Photo
object SamplePhotoSource {
fun loadPhotos(): List<Photo> {
return listOf(
Photo(
id = "1",
uri = "file:///sdcard/Pictures/20200115_181335.jpg",
title = "Sample One",
timestamp = System.currentTimeMillis()
),
Photo(
id = "2",
uri = "file:///sdcard/Pictures/20200115_181417.jpg",
title = "Sample Two",
timestamp = System.currentTimeMillis()
)
)
}
}
// /sdcard/Pictures/20200115_181335.jpg
// /sdcard/Pictures/20200115_181417.jpg

View File

@@ -1,11 +0,0 @@
package com.placeholder.sherpai2.data.repo
import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.data.photos.SamplePhotoSource
class PhotoRepository {
fun getPhotos(): List<Photo> {
return SamplePhotoSource.loadPhotos()
}
}

View File

@@ -0,0 +1,61 @@
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.ImageAggregateDao
import com.placeholder.sherpai2.data.local.dao.ImageEventDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.TagDao
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
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"sherpai.db"
).build()
}
// --- Add these DAO providers ---
@Provides
fun provideTagDao(database: AppDatabase): TagDao {
return database.tagDao()
}
@Provides
fun provideImageTagDao(database: AppDatabase): ImageTagDao {
return database.imageTagDao()
}
// Add providers for your other DAOs now to avoid future errors
@Provides
fun provideImageDao(database: AppDatabase) = database.imageDao()
@Provides
fun providePersonDao(database: AppDatabase) = database.personDao()
@Provides
fun provideEventDao(database: AppDatabase) = database.eventDao()
@Provides
fun provideImageEventDao(database: AppDatabase): ImageEventDao = database.imageEventDao()
@Provides
fun provideImageAggregateDao(database: AppDatabase): ImageAggregateDao = database.imageAggregateDao()
}

View File

@@ -0,0 +1,29 @@
package com.placeholder.sherpai2.di
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.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindImageRepository(
impl: ImageRepositoryImpl
): ImageRepository
@Binds
@Singleton
abstract fun bindTaggingRepository(
impl: TaggingRepositoryImpl
): TaggingRepository
}

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

@@ -1,42 +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 Tour : AppDestinations(
route = "Tour",
icon = Icons.Default.PhotoLibrary,
label = "Tour"
)
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.Tour,
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,104 +0,0 @@
// In presentation/MainContentArea.kt
package com.placeholder.sherpai2.presentation
import androidx.compose.ui.graphics.Color
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.placeholder.sherpai2.navigation.AppDestinations
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
@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.Tour -> TwinAlbumScreen("New Collections." , "Classics")
AppDestinations.Search -> SimplePlaceholder("Search for your photo.")
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)
)
}
@Composable
fun AlbumPreview(title: String, color: Color) {
Box(
modifier = Modifier
.padding(8.dp)
.aspectRatio(1f) // Makes it a square
.background(color, shape = RoundedCornerShape(12.dp)),
contentAlignment = Alignment.Center
) {
Text(text = title, color = Color.White, fontWeight = FontWeight.Bold)
}
}
@Composable
fun TwinAlbumScreen(albumNameA: String, albumNameB: String) {
Column(modifier = Modifier.fillMaxSize()) {
// --- Top 40% Section ---
Row(
modifier = Modifier
.fillMaxWidth()
.weight(0.30f) // This takes exactly 40% of vertical space
.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
// These two occupy the 40% area
Box(modifier = Modifier.weight(1f)) {
AlbumPreview("Mackenzie Hazer", Color.Blue)
}
Box(modifier = Modifier.weight(1f)) {
AlbumPreview("Winifred", Color.Magenta)
}
}
// --- Bottom 60% Section ---
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.6f) // This takes the remaining 60%
.background(Color.LightGray.copy(alpha = 0.2f))
) {
Text("Other content goes here", modifier = Modifier.align(Alignment.Center))
}
}
}
@Preview
@Composable
fun PreviewTwinTopRow() {
SherpAI2Theme {
TwinAlbumScreen("Album A", "Album B")
}
}

View File

@@ -1,35 +0,0 @@
package com.placeholder.sherpai2.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import com.placeholder.sherpai2.data.photos.Photo
import com.placeholder.sherpai2.data.photos.SamplePhotoSource
@Composable
fun PhotoListScreen(
photos: List<Photo>
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
items(photos, key = { it.id }) { photo ->
Image(
painter = rememberAsyncImagePainter(photo.uri),
contentDescription = photo.title,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 8.dp)
)
}
}
}

View File

@@ -0,0 +1,17 @@
package com.placeholder.sherpai2.ui.devscreens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun DummyScreen(label: String) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(label)
}
}

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,46 @@
package com.placeholder.sherpai2.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Drawer-only metadata.
*
* These objects:
* - Drive the drawer UI
* - Provide labels and icons
* - Map cleanly to navigation routes
*/
sealed class AppDestinations(
val route: String,
val icon: ImageVector,
val label: String
) {
object Tour : AppDestinations(AppRoutes.TOUR, Icons.Default.PhotoLibrary, "Tour")
object Search : AppDestinations(AppRoutes.SEARCH, Icons.Default.Search, "Search")
object Models : AppDestinations(AppRoutes.MODELS, Icons.Default.Layers, "Models")
object Inventory : AppDestinations(AppRoutes.INVENTORY, Icons.Default.Inventory2, "Inv")
object Train : AppDestinations(AppRoutes.TRAIN, Icons.Default.TrackChanges, "Train")
object Tags : AppDestinations(AppRoutes.TAGS, Icons.Default.LocalOffer, "Tags")
object ImageDetails : AppDestinations(AppRoutes.IMAGE_DETAIL, Icons.Default.LocalOffer, "IMAGE_DETAIL")
object Upload : AppDestinations(AppRoutes.UPLOAD, Icons.Default.CloudUpload, "Upload")
object Settings : AppDestinations(AppRoutes.SETTINGS, Icons.Default.Settings, "Settings")
}
val mainDrawerItems = listOf(
AppDestinations.Tour,
AppDestinations.Search,
AppDestinations.Models,
AppDestinations.Inventory,
AppDestinations.Train,
AppDestinations.Tags,
AppDestinations.ImageDetails
)
val utilityDrawerItems = listOf(
AppDestinations.Upload,
AppDestinations.Settings
)

View File

@@ -0,0 +1,145 @@
package com.placeholder.sherpai2.ui.navigation
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
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.imagedetail.ImageDetailScreen
import com.placeholder.sherpai2.ui.search.SearchScreen
import com.placeholder.sherpai2.ui.search.SearchViewModel
import java.net.URLDecoder
import java.net.URLEncoder
import com.placeholder.sherpai2.ui.tour.TourViewModel
import com.placeholder.sherpai2.ui.tour.TourScreen
import com.placeholder.sherpai2.ui.trainingprep.ImageSelectorScreen
import com.placeholder.sherpai2.ui.trainingprep.TrainingScreen
import com.placeholder.sherpai2.ui.navigation.AppRoutes
import com.placeholder.sherpai2.ui.navigation.AppRoutes.ScanResultsScreen
import com.placeholder.sherpai2.ui.trainingprep.ScanningState
import com.placeholder.sherpai2.ui.trainingprep.TrainViewModel
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.placeholder.sherpai2.ui.trainingprep.ScanResultsScreen
@Composable
fun AppNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = AppRoutes.SEARCH,
modifier = modifier
) {
/** SEARCH SCREEN **/
composable(AppRoutes.SEARCH) {
val searchViewModel: SearchViewModel = hiltViewModel()
SearchScreen(
searchViewModel = searchViewModel,
onImageClick = { imageUri ->
// Encode the URI to safely pass as argument
val encodedUri = URLEncoder.encode(imageUri, "UTF-8")
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri")
}
)
}
/** IMAGE DETAIL SCREEN **/
composable(
route = "${AppRoutes.IMAGE_DETAIL}/{imageUri}",
arguments = listOf(
navArgument("imageUri") {
type = NavType.StringType
}
)
) { backStackEntry ->
// Decode URI to restore original value
val imageUri = backStackEntry.arguments?.getString("imageUri")
?.let { URLDecoder.decode(it, "UTF-8") }
?: error("imageUri missing from navigation")
ImageDetailScreen(
imageUri = imageUri,
onBack = { navController.popBackStack() }
)
}
composable(AppRoutes.TOUR) {
val tourViewModel: TourViewModel = hiltViewModel()
TourScreen(
tourViewModel = tourViewModel,
onImageClick = { imageUri ->
navController.navigate("${AppRoutes.IMAGE_DETAIL}/$imageUri")
}
)
}
/** TRAINING FLOW **/
composable(AppRoutes.TRAIN) { entry ->
val trainViewModel: TrainViewModel = hiltViewModel()
val uiState by trainViewModel.uiState.collectAsState()
// Observe the result from the ImageSelector
val selectedUris = entry.savedStateHandle.get<List<Uri>>("selected_image_uris")
// If we have new URIs and we are currently Idle, start scanning
LaunchedEffect(selectedUris) {
if (selectedUris != null && uiState is ScanningState.Idle) {
trainViewModel.scanAndTagFaces(selectedUris)
// Clear the handle so it doesn't re-trigger on configuration change
entry.savedStateHandle.remove<List<Uri>>("selected_image_uris")
}
}
if (uiState is ScanningState.Idle) {
// Initial state: Show start button or prompt
TrainingScreen(
onSelectImages = { navController.navigate(AppRoutes.IMAGE_SELECTOR) }
)
} else {
// Processing or Success state: Show the results screen
ScanResultsScreen(
state = uiState,
onFinish = {
navController.navigate(AppRoutes.SEARCH) {
popUpTo(AppRoutes.TRAIN) { inclusive = true }
}
}
)
}
}
composable(AppRoutes.IMAGE_SELECTOR) {
ImageSelectorScreen(
onImagesSelected = { uris ->
navController.previousBackStackEntry
?.savedStateHandle
?.set("selected_image_uris", uris)
navController.popBackStack()
}
)
}
/** DUMMY SCREENS FOR OTHER DRAWER ITEMS **/
//composable(AppRoutes.TOUR) { DummyScreen("Tour (stub)") }
composable(AppRoutes.MODELS) { DummyScreen("Models (stub)") }
composable(AppRoutes.INVENTORY) { DummyScreen("Inventory (stub)") }
//composable(AppRoutes.TRAIN) { DummyScreen("Train (stub)") }
composable(AppRoutes.TAGS) { DummyScreen("Tags (stub)") }
composable(AppRoutes.UPLOAD) { DummyScreen("Upload (stub)") }
composable(AppRoutes.SETTINGS) { DummyScreen("Settings (stub)") }
}
}

View File

@@ -0,0 +1,32 @@
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 {
const val TOUR = "tour"
const val SEARCH = "search"
const val MODELS = "models"
const val INVENTORY = "inv"
const val TRAIN = "train"
const val TAGS = "tags"
const val UPLOAD = "upload"
const val SETTINGS = "settings"
const val IMAGE_DETAIL = "IMAGE_DETAIL"
const val CROP_SCREEN = "CROP_SCREEN"
const val IMAGE_SELECTOR = "Image Selection"
const val TRAINING_SCREEN = "TRAINING_SCREEN"
const val ScanResultsScreen = "First Scan Results"
//const val IMAGE_DETAIL = "IMAGE_DETAIL"
}

View File

@@ -0,0 +1,85 @@
package com.placeholder.sherpai2.ui.presentation
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material3.DividerDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.automirrored.filled.List
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.HorizontalDivider
import com.placeholder.sherpai2.ui.navigation.AppRoutes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppDrawerContent(
currentRoute: String?,
onDestinationClicked: (String) -> Unit
) {
// Drawer sheet with fixed width
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
// Header / Logo
Text(
"SherpAI Control Panel",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(16.dp)
)
HorizontalDivider(
Modifier.fillMaxWidth(),
thickness = DividerDefaults.Thickness,
color = DividerDefaults.color
)
// Main drawer items
val mainItems = listOf(
Triple(AppRoutes.SEARCH, "Search", Icons.Default.Search),
Triple(AppRoutes.TOUR, "Tour", Icons.Default.Place),
Triple(AppRoutes.MODELS, "Models", Icons.Default.ModelTraining),
Triple(AppRoutes.INVENTORY, "Inventory", Icons.AutoMirrored.Filled.List),
Triple(AppRoutes.TRAIN, "Train", Icons.Default.Train),
Triple(AppRoutes.TAGS, "Tags", Icons.AutoMirrored.Filled.Label)
)
Column(modifier = Modifier.padding(vertical = 8.dp)) {
mainItems.forEach { (route, label, icon) ->
NavigationDrawerItem(
label = { Text(label) },
icon = { Icon(icon, contentDescription = label) },
selected = route == currentRoute,
onClick = { onDestinationClicked(route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
Divider(
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
thickness = DividerDefaults.Thickness
)
// Utility items
val utilityItems = listOf(
Triple(AppRoutes.UPLOAD, "Upload", Icons.Default.UploadFile),
Triple(AppRoutes.SETTINGS, "Settings", Icons.Default.Settings)
)
Column(modifier = Modifier.padding(vertical = 8.dp)) {
utilityItems.forEach { (route, label, icon) ->
NavigationDrawerItem(
label = { Text(label) },
icon = { Icon(icon, contentDescription = label) },
selected = route == currentRoute,
onClick = { onDestinationClicked(route) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
}

View File

@@ -0,0 +1,68 @@
package com.placeholder.sherpai2.ui.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 androidx.navigation.NavController
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
// Navigation controller for NavHost
val navController = rememberNavController()
// Track current backstack entry to update top bar title dynamically
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route ?: AppRoutes.SEARCH
// Drawer content for navigation
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
AppDrawerContent(
currentRoute = currentRoute,
onDestinationClicked = { route ->
scope.launch {
drawerState.close()
if (route != currentRoute) {
navController.navigate(route) {
// Avoid multiple copies of the same destination
launchSingleTop = true
}
}
}
}
)
},
) {
// Main scaffold with top bar
Scaffold(
topBar = {
TopAppBar(
title = { Text(currentRoute.replaceFirstChar { it.uppercase() }) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Open Drawer")
}
}
)
}
) { paddingValues ->
AppNavHost(
navController = navController,
modifier = Modifier.padding(paddingValues)
)
}
}
}

View File

@@ -0,0 +1,70 @@
package com.placeholder.sherpai2.ui.search
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
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 com.placeholder.sherpai2.ui.search.components.ImageGridItem
import com.placeholder.sherpai2.ui.search.SearchViewModel
/**
* SearchScreen
*
* Purpose:
* - Validate tag-based queries
* - Preview matching images
*
* This is NOT final UX.
* It is a diagnostic surface.
*/
@Composable
fun SearchScreen(
modifier: Modifier = Modifier,
searchViewModel: SearchViewModel,
onImageClick: (String) -> Unit
) {
var query by remember { mutableStateOf("") }
/**
* Reactive result set.
* Updates whenever:
* - query changes
* - database changes
*/
val images by searchViewModel
.searchImagesByTag(query)
.collectAsStateWithLifecycle(initialValue = emptyList())
Column(
modifier = modifier
.fillMaxSize()
.padding(12.dp)
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("Search by tag") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Adaptive(120.dp),
contentPadding = PaddingValues(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.fillMaxSize()
) {
items(images) { imageWithEverything ->
ImageGridItem(image = imageWithEverything.image)
}
}
}
}

View File

@@ -0,0 +1,24 @@
package com.placeholder.sherpai2.ui.search
import androidx.lifecycle.ViewModel
import com.placeholder.sherpai2.domain.repository.ImageRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
/**
* SearchViewModel
*
* Stateless except for query-driven flows.
*/
@HiltViewModel
class SearchViewModel @Inject constructor(
private val imageRepository: ImageRepository
) : ViewModel() {
fun searchImagesByTag(tag: String) =
if (tag.isBlank()) {
imageRepository.getAllImages()
} else {
imageRepository.findImagesByTag(tag)
}
}

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

@@ -1,141 +0,0 @@
package com.placeholder.sherpai2.ui.tourscreen
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.sherpai2.data.photos.Album
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.placeholder.sherpai2.presentation.AlbumPreview
class GalleryScreen {
}
@Composable
fun GalleryContent(topAlbums: List<Album>) {
Column(modifier = Modifier.fillMaxSize()) {
// --- Top 40% Section ---
Row(
modifier = Modifier
.fillMaxWidth()
.weight(0.4f)
.padding(8.dp)
) {
// We use .getOrNull to safely check if the albums exist in the list
val firstAlbum = topAlbums.getOrNull(0)
val secondAlbum = topAlbums.getOrNull(1)
if (firstAlbum != null) {
AlbumPreviewBoxRandom(album = firstAlbum, modifier = Modifier.weight(1f))
} else {
Box(modifier = Modifier.weight(1f)) // Empty placeholder
}
if (secondAlbum != null) {
AlbumPreviewBoxRandom(album = secondAlbum, modifier = Modifier.weight(1f))
} else {
Box(modifier = Modifier.weight(1f)) // Empty placeholder
}
}
// --- Bottom 60% Section ---
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.6f)
) {
Text("Lower Content Area", modifier = Modifier.align(Alignment.Center))
}
}
}
// --- STATEFUL (Use this for Production) ---
@Composable
fun GalleryScreen(viewModel: GalleryViewModel = viewModel()) {
val albumList by viewModel.albums
GalleryContent(topAlbums = albumList)
}
// --- PREVIEW ---
@Preview(showBackground = true)
@Composable
fun GalleryPreview() {
SherpAI2Theme {
// Feed mock data into the Stateless version
GalleryContent(topAlbums = listOf(/* Fake Album Objects */))
}
}
@Composable
fun AlbumPreviewBox(
album: Album,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.padding(8.dp)
.aspectRatio(1f), // Keeps it square
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.DarkGray), // Background until image is loaded
contentAlignment = Alignment.BottomStart
) {
// Title Overlay
Text(
text = album.title,
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.5f))
.padding(8.dp),
color = Color.White,
style = MaterialTheme.typography.labelLarge
)
}
}
}
@Composable
fun AlbumPreviewBoxRandom(album: Album, modifier: Modifier = Modifier) {
Card(
modifier = modifier.padding(8.dp).aspectRatio(1f),
shape = RoundedCornerShape(12.dp)
) {
Row(modifier = Modifier.fillMaxSize()) {
// Loop through the 3 random photos in the album
album.photos.forEach { photo ->
AsyncImage(
model = photo.uri,
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
contentScale = ContentScale.Crop // Fills the space nicely
)
}
}
}
}

View File

@@ -1,97 +0,0 @@
package com.placeholder.sherpai2.ui.tourscreen
import android.os.Environment
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.placeholder.sherpai2.data.photos.AlbumPhoto
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.photos.Album // Import your data model
import com.placeholder.sherpai2.data.photos.Photo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class GalleryViewModel : ViewModel() {
// This is the 'albums' reference your UI is looking for
private val _albums = mutableStateOf<List<Album>>(emptyList())
val albums: State<List<Album>> = _albums
init {
fetchInitialData()
}
private fun fetchInitialData() {
viewModelScope.launch(Dispatchers.IO) {
val directory = File("/sdcard/photos") // Or: Environment.getExternalStorageDirectory()
if (directory.exists() && directory.isDirectory) {
val photoFiles = directory.listFiles { file ->
file.extension.lowercase() in listOf("jpg", "jpeg", "png")
} ?: emptyArray()
// Map files to your Photo data class
val loadedPhotos = photoFiles.map { file ->
Photo(
id = file.absolutePath,
uri = file.toURI().toString(),
title = file.nameWithoutExtension,
timestamp = file.lastModified()
)
}
// Create an Album object with these photos
val mainAlbum = Album(
id = "local_photos",
title = "Phone Gallery",
photos = loadedPhotos
)
// Update the UI State on the Main Thread
withContext(Dispatchers.Main) {
_albums.value = listOf(mainAlbum)
}
}
}
}
private fun fetchInitialDataRandom() {
viewModelScope.launch(Dispatchers.IO) {
val root = Environment.getExternalStorageDirectory()
val directory = File(root, "photos")
if (directory.exists() && directory.isDirectory) {
// 1. Filter specifically for .jpg files
val photoFiles = directory.listFiles { file ->
file.extension.lowercase() == "jpg"
} ?: emptyArray()
// 2. Shuffle the list and take only the first 3
val randomPhotos = photoFiles.asSequence()
.shuffled()
.take(3)
.map { file ->
Photo(
id = file.absolutePath,
uri = file.toURI().toString(),
title = file.nameWithoutExtension,
timestamp = file.lastModified()
)
}.toList()
// 3. Update the state with these 3 random photos
val mainAlbum = Album(
id = "random_selection",
title = "Random Picks",
photos = randomPhotos
)
withContext(Dispatchers.Main) {
_albums.value = listOf(mainAlbum)
}
}
}
}
}

View File

@@ -1,4 +0,0 @@
package com.placeholder.sherpai2.ui.tourscreen.components
class AlbumBox {
}

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,56 @@
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
}
// 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,130 @@
package com.placeholder.sherpai2.ui.trainingprep
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
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.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Text
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import androidx.compose.foundation.lazy.grid.items
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageSelectorScreen(
onImagesSelected: (List<Uri>) -> Unit
) {
//1. Persist state across configuration changes
var selectedUris by rememberSaveable { mutableStateOf<List<Uri>>(emptyList()) }
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
) { uris ->
// 2. Take first 10 and try to persist permissions
val limitedUris = uris.take(10)
selectedUris = limitedUris
}
Scaffold(
topBar = { TopAppBar(title = { Text("Select Training Photos") }) }
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedCard(
onClick = { launcher.launch(arrayOf("image/*")) },
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(Icons.Default.AddPhotoAlternate, contentDescription = null)
Spacer(Modifier.height(8.dp))
Text("Select up to 10 images of the person")
Text(
text = "${selectedUris.size} / 10 selected",
style = MaterialTheme.typography.labelLarge,
color = if (selectedUris.size == 10) MaterialTheme.colorScheme.error
else if (selectedUris.isNotEmpty()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outline
)
}
}
// 3. Conditional rendering for empty state
if (selectedUris.isEmpty()) {
Box(Modifier
.weight(1f)
.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No images selected", style = MaterialTheme.typography.bodyMedium)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp)
) {
items(selectedUris, key = { it.toString() }) { uri ->
Box(modifier = Modifier.padding(4.dp)) {
AsyncImage(
model = uri,
contentDescription = null,
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
// 4. Ability to remove specific images
Surface(
onClick = { selectedUris = selectedUris - uri },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
Button(
modifier = Modifier.fillMaxWidth(),
enabled = selectedUris.isNotEmpty(),
onClick = { onImagesSelected(selectedUris) }
) {
Text("Start Face Detection")
}
}
}
}

View File

@@ -0,0 +1,667 @@
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.clickable
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.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.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) }
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 = onFinish,
onRetry = onFinish,
onReplaceImage = { oldUri, newUri ->
trainViewModel.replaceImage(oldUri, newUri)
},
onSelectFaceFromMultiple = { result ->
showFacePickerDialog = result
}
)
}
is ScanningState.Error -> {
ErrorView(
message = state.message,
onRetry = onFinish
)
}
}
}
}
// Face Picker Dialog
showFacePickerDialog?.let { result ->
FacePickerDialog(
result = result,
onDismiss = { showFacePickerDialog = null },
onFaceSelected = { faceIndex, croppedFaceBitmap ->
trainViewModel.selectFaceFromImage(result.uri, faceIndex, croppedFaceBitmap)
showFacePickerDialog = null
}
)
}
}
@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
)
}
Divider(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,253 @@
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 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()
}
@HiltViewModel
class TrainViewModel @Inject constructor(
application: Application
) : 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()
// Keep track of current images for replacements
private var currentImageUris: List<Uri> = emptyList()
// Keep track of manual face selections (imageUri -> selectedFaceIndex)
private val manualFaceSelections = mutableMapOf<Uri, ManualFaceSelection>()
data class ManualFaceSelection(
val faceIndex: Int,
val croppedFaceBitmap: Bitmap
)
/**
* Scan and validate images for training
*/
fun scanAndTagFaces(imageUris: List<Uri>) {
currentImageUris = imageUris
manualFaceSelections.clear()
performScan(imageUris)
}
/**
* Replace a single image and re-scan
*/
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
// Remove manual selection for old URI if any
manualFaceSelections.remove(oldUri)
// Re-scan all images
performScan(currentImageUris)
}
}
}
/**
* User manually selected a face from a multi-face image
*/
fun selectFaceFromImage(imageUri: Uri, faceIndex: Int, croppedFaceBitmap: Bitmap) {
manualFaceSelections[imageUri] = ManualFaceSelection(faceIndex, croppedFaceBitmap)
// Re-process the results with the manual selection
val currentState = _uiState.value
if (currentState is ScanningState.Success) {
val updatedResult = applyManualSelections(currentState.sanityCheckResult)
_uiState.value = ScanningState.Success(updatedResult)
}
}
/**
* Perform the actual scanning
*/
private fun performScan(imageUris: List<Uri>) {
viewModelScope.launch {
try {
_uiState.value = ScanningState.Processing(0, imageUris.size)
// Perform sanity checks
val result = sanityChecker.performSanityChecks(
imageUris = imageUris,
minImagesRequired = 10,
allowMultipleFaces = true, // Allow multiple faces - user can pick
duplicateSimilarityThreshold = 0.95
)
// Apply any manual face selections
val finalResult = applyManualSelections(result)
_uiState.value = ScanningState.Success(finalResult)
} catch (e: Exception) {
_uiState.value = ScanningState.Error(
e.message ?: "An unknown error occurred"
)
}
}
}
/**
* Apply manual face selections to the results
*/
private fun applyManualSelections(
result: TrainingSanityChecker.SanityCheckResult
): TrainingSanityChecker.SanityCheckResult {
// If no manual selections, return original
if (manualFaceSelections.isEmpty()) {
return result
}
// Update face detection results with manual selections
val updatedFaceResults = result.faceDetectionResults.map { faceResult ->
val manualSelection = manualFaceSelections[faceResult.uri]
if (manualSelection != null) {
// Replace the cropped face with the manually selected one
faceResult.copy(
croppedFaceBitmap = manualSelection.croppedFaceBitmap,
// Treat as single face since user selected one
faceCount = 1
)
} else {
faceResult
}
}
// Update valid images list
val updatedValidImages = updatedFaceResults
.filter { it.hasFace }
.filter { it.croppedFaceBitmap != null }
.filter { it.errorMessage == null }
.filter { it.faceCount >= 1 } // Now accept if user picked a face
.map { result ->
TrainingSanityChecker.ValidTrainingImage(
uri = result.uri,
croppedFaceBitmap = result.croppedFaceBitmap!!,
faceCount = result.faceCount
)
}
// Recalculate validation errors
val updatedErrors = result.validationErrors.toMutableList()
// Remove multiple face errors for images with manual selections
updatedErrors.removeAll { error ->
error is TrainingSanityChecker.ValidationError.MultipleFacesDetected &&
manualFaceSelections.containsKey(error.uri)
}
// Check if we have enough valid images now
if (updatedValidImages.size < 10) {
if (updatedErrors.none { it is TrainingSanityChecker.ValidationError.InsufficientImages }) {
updatedErrors.add(
TrainingSanityChecker.ValidationError.InsufficientImages(
required = 10,
available = updatedValidImages.size
)
)
}
} else {
// Remove insufficient images error if we now have enough
updatedErrors.removeAll { it is TrainingSanityChecker.ValidationError.InsufficientImages }
}
val isValid = updatedErrors.isEmpty() && updatedValidImages.size >= 10
return result.copy(
isValid = isValid,
faceDetectionResults = updatedFaceResults,
validationErrors = updatedErrors,
validImagesWithFaces = updatedValidImages
)
}
/**
* Get formatted error messages
*/
fun getFormattedErrors(result: TrainingSanityChecker.SanityCheckResult): List<String> {
return sanityChecker.formatValidationErrors(result.validationErrors)
}
/**
* Reset to idle state
*/
fun reset() {
_uiState.value = ScanningState.Idle
currentImageUris = emptyList()
manualFaceSelections.clear()
}
override fun onCleared() {
super.onCleared()
sanityChecker.cleanup()
faceDetectionHelper.cleanup()
}
}
// Extension function to copy FaceDetectionResult with modifications
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
)
}
// Extension function to copy SanityCheckResult with modifications
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,31 @@
package com.placeholder.sherpai2.ui.trainingprep
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrainingScreen(
onSelectImages: () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Training") }
)
}
) { padding ->
Button(
modifier = Modifier.padding(padding),
onClick = onSelectImages
) {
Text("Select Images")
}
}
}

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

@@ -3,4 +3,9 @@ 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

@@ -1,32 +1,65 @@
[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"
[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"}
[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