Compare commits
2 Commits
dba64b89b6
...
cleanup_co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e388e9a64 | ||
|
|
16eecf6c08 |
1
.idea/.name
generated
1
.idea/.name
generated
@@ -1 +0,0 @@
|
|||||||
SherpAI2
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
@@ -1,81 +1,77 @@
|
|||||||
|
// build.gradle.kts (Module: :app)
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
// 1. Core Android and Kotlin plugins (MUST be first)
|
||||||
alias(libs.plugins.kotlin.android)
|
id("com.android.application")
|
||||||
alias(libs.plugins.kotlin.compose)
|
kotlin("android")
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.hilt.android)
|
id("org.jetbrains.kotlin.plugin.compose") // Note: No version is specified here
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
// 2. Android Configuration
|
||||||
namespace = "com.placeholder.sherpai2"
|
namespace = "com.placeholder.sherpai2"
|
||||||
compileSdk = 35
|
compileSdk = 34
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.placeholder.sherpai2"
|
applicationId = "com.placeholder.sherpai2"
|
||||||
minSdk = 25
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "1.0"
|
versionName = "1.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
// 3. Kotlin & Java Settings
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Jetpack Compose Configuration (Crucial!)
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8" // Must match your Kotlin version
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// Core & Lifecycle
|
// --- CORE ANDROID & LIFECYCLE ---
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation("androidx.activity:activity-compose:1.8.2") // Fixes 'activity' ref error
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
|
|
||||||
// Compose
|
// --- JETPACK COMPOSE UI (Material 3) ---
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation("androidx.compose.ui:ui")
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation("androidx.compose.material3:material3") // Fixes 'material3' ref error
|
||||||
implementation(libs.androidx.compose.material3)
|
|
||||||
implementation(libs.androidx.compose.material.icons)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
|
||||||
|
|
||||||
// Hilt DI
|
// --- COMPOSE ICONS (Fixes 'material' and 'Icons' ref errors) ---
|
||||||
implementation(libs.hilt.android)
|
// Uses direct string to avoid Version Catalog conflicts
|
||||||
ksp(libs.hilt.compiler)
|
implementation("androidx.compose.material:material-icons-extended:1.6.0")
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
|
||||||
|
|
||||||
// Navigation
|
// --- STATE MANAGEMENT / COROUTINES ---
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
// Room Database
|
// --- TESTING ---
|
||||||
implementation(libs.room.runtime)
|
testImplementation("junit:junit:4.13.2")
|
||||||
implementation(libs.room.ktx)
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
ksp(libs.room.compiler)
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||||
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||||
// Coil Images
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
implementation(libs.coil.compose)
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
|
||||||
// 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")
|
||||||
}
|
}
|
||||||
@@ -10,8 +10,7 @@
|
|||||||
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"
|
||||||
|
|||||||
@@ -10,77 +10,61 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
import com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen
|
||||||
import com.placeholder.sherpai2.ui.presentation.MainScreen
|
|
||||||
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
|
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
|
// 1. Define the permission needed based on API level
|
||||||
val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
Manifest.permission.READ_MEDIA_IMAGES
|
Manifest.permission.READ_MEDIA_IMAGES
|
||||||
} else {
|
} else {
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
SherpAI2Theme {
|
SherpAI2Theme {
|
||||||
|
// 2. State to track if permission is granted
|
||||||
var hasPermission by remember {
|
var hasPermission by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED)
|
||||||
ContextCompat.checkSelfPermission(this, storagePermission) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track ingestion completion
|
// 3. Launcher to ask for permission
|
||||||
var imagesIngested by remember { mutableStateOf(false) }
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
|
||||||
// Launcher for permission request
|
|
||||||
val permissionLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
ActivityResultContracts.RequestPermission()
|
||||||
) { granted ->
|
) { isGranted ->
|
||||||
hasPermission = granted
|
hasPermission = isGranted
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger ingestion once permission is granted
|
// 4. Trigger request on start
|
||||||
LaunchedEffect(hasPermission) {
|
LaunchedEffect(Unit) {
|
||||||
if (hasPermission) {
|
if (!hasPermission) launcher.launch(permission)
|
||||||
// Suspend until ingestion completes
|
|
||||||
imageRepository.ingestImages()
|
|
||||||
imagesIngested = true
|
|
||||||
} else {
|
|
||||||
permissionLauncher.launch(storagePermission)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gate UI until permission granted AND ingestion completed
|
if (hasPermission) {
|
||||||
if (hasPermission && imagesIngested) {
|
MainScreen() // Your existing screen that holds MainContentArea
|
||||||
MainScreen()
|
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Text("Please grant storage permission to view photos.")
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("Please grant storage permission to continue.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package com.placeholder.sherpai2
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
|
||||||
class SherpAIApplication : Application()
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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>>
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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>>
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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 (0–1).
|
|
||||||
*/
|
|
||||||
val confidence: Float,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUBLIC | PRIVATE | HIDDEN
|
|
||||||
*/
|
|
||||||
val visibility: String,
|
|
||||||
|
|
||||||
val createdAt: Long
|
|
||||||
)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.placeholder.sherpai2.data.photos
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
data class Photo(
|
||||||
|
val id: Long,
|
||||||
|
val uri: Uri,
|
||||||
|
val title: String? = null,
|
||||||
|
val size: Long,
|
||||||
|
val dateModified: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.placeholder.sherpai2.data.repo
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.util.Log
|
||||||
|
import com.placeholder.sherpai2.data.photos.Photo
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class PhotoRepository(private val context: Context) {
|
||||||
|
|
||||||
|
fun scanExternalStorage(): Result<List<Photo>> {
|
||||||
|
// Best Practice: Use Environment.getExternalStorageDirectory()
|
||||||
|
// only as a fallback or starting point for legacy support.
|
||||||
|
val rootPath = Environment.getExternalStorageDirectory()
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
val photos = mutableListOf<Photo>()
|
||||||
|
|
||||||
|
if (rootPath.exists() && rootPath.isDirectory) {
|
||||||
|
// walkTopDown is efficient but can throw AccessDeniedException
|
||||||
|
rootPath.walkTopDown()
|
||||||
|
.maxDepth(3) // Performance Best Practice: Don't scan the whole phone
|
||||||
|
.filter { it.isFile && isImageFile(it.extension) }
|
||||||
|
.forEach { file ->
|
||||||
|
photos.add(mapFileToPhoto(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
photos
|
||||||
|
}.onFailure { e ->
|
||||||
|
Log.e("PhotoRepo", "Failed to scan filesystem", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapFileToPhoto(file: File): Photo {
|
||||||
|
return Photo(
|
||||||
|
id = file.path.hashCode().toLong(),
|
||||||
|
uri = Uri.fromFile(file),
|
||||||
|
title = file.name,
|
||||||
|
size = file.length(),
|
||||||
|
dateModified = (file.lastModified() / 1000).toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isImageFile(ext: String) = listOf("jpg", "jpeg", "png").contains(ext.lowercase())
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.placeholder.sherpai2.domain
|
||||||
|
|
||||||
|
//fun getAllPhotos(context: Context): List<Photo> {
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.placeholder.sherpai2.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
class PhotoDuplicateScanner(private val context: Context) {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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>>
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
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()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// In navigation/AppDestinations.kt
|
||||||
package com.placeholder.sherpai2.ui.navigation
|
package com.placeholder.sherpai2.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -5,39 +6,34 @@ import androidx.compose.material.icons.filled.*
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drawer-only metadata.
|
* Defines all navigation destinations (screens) for the application.
|
||||||
*
|
|
||||||
* These objects:
|
|
||||||
* - Drive the drawer UI
|
|
||||||
* - Provide labels and icons
|
|
||||||
* - Map cleanly to navigation routes
|
|
||||||
*/
|
*/
|
||||||
sealed class AppDestinations(
|
sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) {
|
||||||
val route: String,
|
// Core Functional Sections
|
||||||
val icon: ImageVector,
|
object Tour : AppDestinations(
|
||||||
val label: String
|
route = "Tour",
|
||||||
) {
|
icon = Icons.Default.PhotoLibrary,
|
||||||
object Tour : AppDestinations(AppRoutes.TOUR, Icons.Default.PhotoLibrary, "Tour")
|
label = "Tour"
|
||||||
object Search : AppDestinations(AppRoutes.SEARCH, Icons.Default.Search, "Search")
|
)
|
||||||
object Models : AppDestinations(AppRoutes.MODELS, Icons.Default.Layers, "Models")
|
object Search : AppDestinations("search", Icons.Default.Search, "Search")
|
||||||
object Inventory : AppDestinations(AppRoutes.INVENTORY, Icons.Default.Inventory2, "Inv")
|
object Models : AppDestinations("models", Icons.Default.Layers, "Models")
|
||||||
object Train : AppDestinations(AppRoutes.TRAIN, Icons.Default.TrackChanges, "Train")
|
object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv")
|
||||||
object Tags : AppDestinations(AppRoutes.TAGS, Icons.Default.LocalOffer, "Tags")
|
object Train : AppDestinations("train", Icons.Default.TrackChanges, "Train")
|
||||||
|
object Tags : AppDestinations("tags", Icons.Default.LocalOffer, "Tags")
|
||||||
|
|
||||||
object ImageDetails : AppDestinations(AppRoutes.IMAGE_DETAIL, Icons.Default.LocalOffer, "IMAGE_DETAIL")
|
// Utility/Secondary Sections
|
||||||
|
object Upload : AppDestinations("upload", Icons.Default.CloudUpload, "Upload")
|
||||||
object Upload : AppDestinations(AppRoutes.UPLOAD, Icons.Default.CloudUpload, "Upload")
|
object Settings : AppDestinations("settings", Icons.Default.Settings, "Settings")
|
||||||
object Settings : AppDestinations(AppRoutes.SETTINGS, Icons.Default.Settings, "Settings")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lists used by the AppDrawerContent to render the menu sections easily
|
||||||
val mainDrawerItems = listOf(
|
val mainDrawerItems = listOf(
|
||||||
AppDestinations.Tour,
|
AppDestinations.Tour,
|
||||||
AppDestinations.Search,
|
AppDestinations.Search,
|
||||||
AppDestinations.Models,
|
AppDestinations.Models,
|
||||||
AppDestinations.Inventory,
|
AppDestinations.Inventory,
|
||||||
AppDestinations.Train,
|
AppDestinations.Train,
|
||||||
AppDestinations.Tags,
|
AppDestinations.Tags
|
||||||
AppDestinations.ImageDetails
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val utilityDrawerItems = listOf(
|
val utilityDrawerItems = listOf(
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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)") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// In presentation/MainScreen.kt
|
||||||
|
package com.placeholder.sherpai2.presentation
|
||||||
|
|
||||||
|
import GalleryViewModel
|
||||||
|
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.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.placeholder.sherpai2.ui.navigation.AppDestinations
|
||||||
|
import com.placeholder.sherpai2.ui.presentation.AppDrawerContent
|
||||||
|
import com.placeholder.sherpai2.ui.presentation.MainContentArea
|
||||||
|
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) }
|
||||||
|
val galleryViewModel: GalleryViewModel = viewModel()
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
galleryViewModel = galleryViewModel,
|
||||||
|
modifier = Modifier.padding(paddingValues)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +1,56 @@
|
|||||||
|
// In presentation/AppDrawerContent.kt
|
||||||
package com.placeholder.sherpai2.ui.presentation
|
package com.placeholder.sherpai2.ui.presentation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.DividerDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material.icons.Icons
|
import com.placeholder.sherpai2.ui.navigation.AppDestinations
|
||||||
import androidx.compose.material.icons.automirrored.filled.Label
|
import com.placeholder.sherpai2.ui.navigation.mainDrawerItems
|
||||||
import androidx.compose.material.icons.automirrored.filled.List
|
import com.placeholder.sherpai2.ui.navigation.utilityDrawerItems
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import com.placeholder.sherpai2.ui.navigation.AppRoutes
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppDrawerContent(
|
fun AppDrawerContent(
|
||||||
currentRoute: String?,
|
currentScreen: AppDestinations,
|
||||||
onDestinationClicked: (String) -> Unit
|
onDestinationClicked: (AppDestinations) -> Unit
|
||||||
) {
|
) {
|
||||||
// Drawer sheet with fixed width
|
// Defines the width and content of the sliding drawer panel
|
||||||
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
|
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
|
||||||
|
|
||||||
// Header / Logo
|
// Header/Logo Area
|
||||||
Text(
|
Text(
|
||||||
"SherpAI Control Panel",
|
"SherpAI Control Panel",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
|
Divider(Modifier.fillMaxWidth())
|
||||||
|
|
||||||
HorizontalDivider(
|
// 1. Main Navigation Items
|
||||||
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)) {
|
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
mainItems.forEach { (route, label, icon) ->
|
mainDrawerItems.forEach { destination ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text(label) },
|
label = { Text(destination.label) },
|
||||||
icon = { Icon(icon, contentDescription = label) },
|
icon = { Icon(destination.icon, contentDescription = destination.label) },
|
||||||
selected = route == currentRoute,
|
selected = destination == currentScreen,
|
||||||
onClick = { onDestinationClicked(route) },
|
onClick = { onDestinationClicked(destination) },
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider(
|
// Separator
|
||||||
Modifier
|
Divider(Modifier.fillMaxWidth().padding(vertical = 8.dp))
|
||||||
.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)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// 2. Utility Items
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||||
utilityItems.forEach { (route, label, icon) ->
|
utilityDrawerItems.forEach { destination ->
|
||||||
NavigationDrawerItem(
|
NavigationDrawerItem(
|
||||||
label = { Text(label) },
|
label = { Text(destination.label) },
|
||||||
icon = { Icon(icon, contentDescription = label) },
|
icon = { Icon(destination.icon, contentDescription = destination.label) },
|
||||||
selected = route == currentRoute,
|
selected = destination == currentScreen,
|
||||||
onClick = { onDestinationClicked(route) },
|
onClick = { onDestinationClicked(destination) },
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// In presentation/MainContentArea.kt
|
||||||
|
package com.placeholder.sherpai2.ui.presentation
|
||||||
|
|
||||||
|
import GalleryScreen
|
||||||
|
import GalleryViewModel
|
||||||
|
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.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.ui.navigation.AppDestinations
|
||||||
|
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
@Composable
|
||||||
|
fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier, galleryViewModel: GalleryViewModel = viewModel() ) {
|
||||||
|
val uiState by galleryViewModel.uiState.collectAsState()
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Red),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
// Swaps the UI content based on the selected screen from the drawer
|
||||||
|
when (currentScreen) {
|
||||||
|
AppDestinations.Tour -> GalleryScreen(state = uiState, modifier = Modifier)
|
||||||
|
AppDestinations.Search -> SimplePlaceholder("Find Any Photos.")
|
||||||
|
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).background(color = Color.Magenta)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.placeholder.sherpai2.ui.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
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
// 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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
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.AsyncImage
|
||||||
|
import com.placeholder.sherpai2.data.photos.Photo
|
||||||
|
import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GalleryScreen(
|
||||||
|
state: GalleryUiState,
|
||||||
|
modifier: Modifier = Modifier // Add default modifier
|
||||||
|
) {
|
||||||
|
// Note: If this is inside MainContentArea, you might not need a second Scaffold.
|
||||||
|
// Let's use a Column or Box to ensure it fills the space correctly.
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Photo Gallery",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
)
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
when (state) {
|
||||||
|
is GalleryUiState.Loading -> CircularProgressIndicator()
|
||||||
|
is GalleryUiState.Error -> Text(text = state.message)
|
||||||
|
is GalleryUiState.Success -> {
|
||||||
|
if (state.photos.isEmpty()) {
|
||||||
|
Text("No photos found. Try adding some to the emulator!")
|
||||||
|
} else {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
items(state.photos) { photo ->
|
||||||
|
PhotoItem(photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Composable
|
||||||
|
fun PhotoItem(photo: Photo) {
|
||||||
|
AsyncImage(
|
||||||
|
model = photo.uri,
|
||||||
|
contentDescription = photo.title,
|
||||||
|
modifier = Modifier
|
||||||
|
.aspectRatio(1f) // Makes it a square
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.placeholder.sherpai2.ui.tourscreen
|
||||||
|
|
||||||
|
import com.placeholder.sherpai2.data.photos.Photo
|
||||||
|
|
||||||
|
sealed class GalleryUiState {
|
||||||
|
object Loading : GalleryUiState()
|
||||||
|
data class Success(val photos: List<Photo>) : GalleryUiState()
|
||||||
|
data class Error(val message: String) : GalleryUiState()
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.placeholder.sherpai2.data.photos.Photo
|
||||||
|
import com.placeholder.sherpai2.data.repo.PhotoRepository
|
||||||
|
import com.placeholder.sherpai2.ui.tourscreen.GalleryUiState
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class GalleryViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
// Initialize repository with the application context
|
||||||
|
private val repository = PhotoRepository(application)
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<GalleryUiState>(GalleryUiState.Loading)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadPhotos()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadPhotos() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.scanExternalStorage()
|
||||||
|
|
||||||
|
result.onSuccess { photos ->
|
||||||
|
_uiState.value = GalleryUiState.Success(photos)
|
||||||
|
}.onFailure { error ->
|
||||||
|
_uiState.value = GalleryUiState.Error(error.message ?: "Unknown Error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.placeholder.sherpai2.ui.tourscreen.components
|
||||||
|
|
||||||
|
class AlbumBox {
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
package com.placeholder.sherpai2.ui.trainingprep
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
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.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil.compose.rememberAsyncImagePainter
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ScanResultsScreen(
|
|
||||||
state: ScanningState,
|
|
||||||
onFinish: () -> Unit
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
when (state) {
|
|
||||||
is ScanningState.Processing -> {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text("Analyzing faces... ${state.current} / ${state.total}")
|
|
||||||
}
|
|
||||||
is ScanningState.Success -> {
|
|
||||||
Text(
|
|
||||||
text = "Analysis Complete!",
|
|
||||||
style = MaterialTheme.typography.headlineMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.weight(1f).padding(vertical = 16.dp)) {
|
|
||||||
items(state.results) { result ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = rememberAsyncImagePainter(result.uri),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)),
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(16.dp))
|
|
||||||
Column {
|
|
||||||
Text(if (result.faceCount > 0) "✅ Face Detected" else "❌ No Face")
|
|
||||||
if (result.hasMultipleFaces) {
|
|
||||||
Text(
|
|
||||||
"⚠️ Multiple faces (${result.faceCount})",
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(onClick = onFinish, modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Text("Done")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package com.placeholder.sherpai2.ui.trainingprep
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.google.mlkit.vision.common.InputImage
|
|
||||||
import com.google.mlkit.vision.face.FaceDetection
|
|
||||||
import com.google.mlkit.vision.face.FaceDetectorOptions
|
|
||||||
import com.placeholder.sherpai2.domain.repository.ImageRepository
|
|
||||||
import com.placeholder.sherpai2.domain.repository.TaggingRepository
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.awaitAll
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Semaphore
|
|
||||||
import kotlinx.coroutines.sync.withPermit
|
|
||||||
import kotlinx.coroutines.tasks.await
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
sealed class ScanningState {
|
|
||||||
object Idle : ScanningState()
|
|
||||||
data class Processing(val current: Int, val total: Int) : ScanningState()
|
|
||||||
data class Success(val results: List<ScanResult>) : ScanningState()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class ScanResult(
|
|
||||||
val uri: Uri,
|
|
||||||
val faceCount: Int,
|
|
||||||
val hasMultipleFaces: Boolean = faceCount > 1
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class TrainViewModel @Inject constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val imageRepository: ImageRepository,
|
|
||||||
private val taggingRepository: TaggingRepository
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow<ScanningState>(ScanningState.Idle)
|
|
||||||
val uiState: StateFlow<ScanningState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private val semaphore = Semaphore(2)
|
|
||||||
|
|
||||||
fun scanAndTagFaces(uris: List<Uri>) = viewModelScope.launch {
|
|
||||||
val total = uris.size
|
|
||||||
_uiState.value = ScanningState.Processing(0, total)
|
|
||||||
|
|
||||||
val detector = FaceDetection.getClient(faceOptions())
|
|
||||||
val allImages = imageRepository.getAllImages().first()
|
|
||||||
val uriToIdMap = allImages.associate { it.image.imageUri to it.image.imageId }
|
|
||||||
|
|
||||||
var completedCount = 0
|
|
||||||
|
|
||||||
val scanResults = withContext(Dispatchers.Default) {
|
|
||||||
uris.map { uri ->
|
|
||||||
async {
|
|
||||||
semaphore.withPermit {
|
|
||||||
val faceCount = detectFaceCount(detector, uri)
|
|
||||||
|
|
||||||
// Tagging logic
|
|
||||||
if (faceCount > 0) {
|
|
||||||
uriToIdMap[uri.toString()]?.let { id ->
|
|
||||||
taggingRepository.addTagToImage(id, "face", "ML_KIT", 1.0f)
|
|
||||||
if (faceCount > 1) {
|
|
||||||
taggingRepository.addTagToImage(id, "multiple_faces", "ML_KIT", 1.0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completedCount++
|
|
||||||
_uiState.value = ScanningState.Processing(completedCount, total)
|
|
||||||
|
|
||||||
ScanResult(uri, faceCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.awaitAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
detector.close()
|
|
||||||
_uiState.value = ScanningState.Success(scanResults)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun detectFaceCount(
|
|
||||||
detector: com.google.mlkit.vision.face.FaceDetector,
|
|
||||||
uri: Uri
|
|
||||||
): Int = withContext(Dispatchers.IO) {
|
|
||||||
return@withContext try {
|
|
||||||
val image = InputImage.fromFilePath(context, uri)
|
|
||||||
val faces = detector.process(image).await()
|
|
||||||
faces.size // Returns actual count
|
|
||||||
} catch (e: Exception) {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun faceOptions() = FaceDetectorOptions.Builder()
|
|
||||||
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,4 @@ 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
|
|
||||||
}
|
}
|
||||||
@@ -1,65 +1,32 @@
|
|||||||
[versions]
|
[versions]
|
||||||
# Tooling
|
agp = "8.13.1"
|
||||||
agp = "8.13.2"
|
|
||||||
kotlin = "2.0.21"
|
kotlin = "2.0.21"
|
||||||
ksp = "2.0.21-1.0.28"
|
coreKtx = "1.17.0"
|
||||||
|
junit = "4.13.2"
|
||||||
# AndroidX / Lifecycle
|
junitVersion = "1.3.0"
|
||||||
coreKtx = "1.15.0"
|
espressoCore = "3.7.0"
|
||||||
lifecycle = "2.8.7"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.9.3"
|
activityCompose = "1.12.1"
|
||||||
composeBom = "2025.12.01"
|
composeBom = "2024.09.00"
|
||||||
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" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
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" }
|
|
||||||
|
|||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -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.14-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Reference in New Issue
Block a user