4 Commits

Author SHA1 Message Date
genki
c458e08075 Correct schema
Meaningful queries
Proper transactional reads
2025-12-24 22:48:34 -05:00
genki
c10cbf373f Working Gallery and Repo - Earlydays! 2025-12-20 18:27:09 -05:00
genki
91f6327c31 CheckPoint save for adding 'Tour' screen, and PhotoData and PhotoViewModels 2025-12-20 18:27:09 -05:00
genki
52fa755a3f Working Gallery and Repo - Earlydays! 2025-12-20 17:57:01 -05:00
42 changed files with 1099 additions and 93 deletions

1
.idea/.name generated Normal file
View File

@@ -0,0 +1 @@
SherpAI2

1
.idea/gradle.xml generated
View File

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

1
.idea/misc.xml generated
View File

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

View File

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

View File

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

View File

@@ -23,5 +23,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
</manifest> </manifest>

View File

@@ -1,29 +1,70 @@
package com.placeholder.sherpai2 package com.placeholder.sherpai2
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.Modifier import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen import com.placeholder.sherpai2.presentation.MainScreen // IMPORT your main screen
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// 1. Define the permission needed based on API level
val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
setContent { setContent {
// Assume you have a Theme file named SherpAI2Theme (standard for new projects) SherpAI2Theme {
// Replace with your actual project theme if different // 2. State to track if permission is granted
MaterialTheme { var hasPermission by remember {
Surface( mutableStateOf(ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED)
modifier = Modifier.fillMaxSize(), }
color = MaterialTheme.colorScheme.background
) { // 3. Launcher to ask for permission
// Launch the main navigation UI val launcher = rememberLauncherForActivityResult(
MainScreen() ActivityResultContracts.RequestPermission()
) { isGranted ->
hasPermission = isGranted
}
// 4. Trigger request on start
LaunchedEffect(Unit) {
if (!hasPermission) launcher.launch(permission)
}
if (hasPermission) {
MainScreen() // Your existing screen that holds MainContentArea
} else {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Please grant storage permission to view photos.")
}
} }
} }
}
} }
} }
}

View File

@@ -0,0 +1,47 @@
package com.placeholder.sherpai2.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.placeholder.sherpai2.data.local.dao.EventDao
import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.dao.ImageEventDao
import com.placeholder.sherpai2.data.local.dao.ImagePersonDao
import com.placeholder.sherpai2.data.local.dao.ImageTagDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.dao.TagDao
import com.placeholder.sherpai2.data.local.entity.EventEntity
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.ImageEventEntity
import com.placeholder.sherpai2.data.local.entity.ImagePersonEntity
import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
@Database(
entities = [
ImageEntity::class,
TagEntity::class,
PersonEntity::class,
EventEntity::class,
ImageTagEntity::class,
ImagePersonEntity::class,
ImageEventEntity::class
],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun imageDao(): ImageDao
abstract fun tagDao(): TagDao
abstract fun personDao(): PersonDao
abstract fun eventDao(): EventDao
abstract fun imageTagDao(): ImageTagDao
abstract fun imagePersonDao(): ImagePersonDao
abstract fun imageEventDao(): ImageEventDao
abstract fun imageAggregateDao(): ImageAggregateDao
}

View File

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

View File

@@ -0,0 +1,25 @@
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.
*
* This is what your detail screen should use.
*/
@Transaction
@Query("""
SELECT * FROM images
WHERE imageId = :imageId
""")
fun observeImageWithEverything(
imageId: String
): Flow<ImageWithEverything>
}

View File

@@ -0,0 +1,56 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import 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>
}

View File

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

View File

@@ -0,0 +1,25 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImagePersonEntity
@Dao
interface ImagePersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ImagePersonEntity)
/**
* All images containing a specific person.
*/
@Query("""
SELECT imageId FROM image_persons
WHERE personId = :personId
AND visibility = 'PUBLIC'
AND confirmed = 1
""")
suspend fun findImagesForPerson(personId: String): List<String>
}

View File

@@ -0,0 +1,41 @@
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.ImageTagEntity
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>
}

View File

@@ -0,0 +1,24 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.PersonEntity
@Dao
interface PersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(person: PersonEntity)
@Query("SELECT * FROM persons WHERE personId = :personId")
suspend fun getById(personId: String): PersonEntity?
@Query("""
SELECT * FROM persons
WHERE isHidden = 0
ORDER BY displayName
""")
suspend fun getVisiblePeople(): List<PersonEntity>
}

View File

@@ -0,0 +1,24 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.TagEntity
@Dao
interface TagDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity)
/**
* Resolve a tag by value.
* Example: "park"
*/
@Query("SELECT * FROM tags WHERE value = :value LIMIT 1")
suspend fun getByValue(value: String): TagEntity?
@Query("SELECT * FROM tags")
suspend fun getAll(): List<TagEntity>
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
)

View File

@@ -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())
}

View File

@@ -0,0 +1,28 @@
package com.placeholder.sherpai2.di
import android.content.Context
import androidx.room.Room
import com.placeholder.sherpai2.data.local.AppDatabase
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()
}
}

View File

@@ -0,0 +1,3 @@
package com.placeholder.sherpai2.domain
//fun getAllPhotos(context: Context): List<Photo> {

View File

@@ -0,0 +1,7 @@
package com.placeholder.sherpai2.domain
import android.content.Context
class PhotoDuplicateScanner(private val context: Context) {
}

View File

@@ -1,5 +1,5 @@
// In navigation/AppDestinations.kt // In navigation/AppDestinations.kt
package com.placeholder.sherpai2.navigation package com.placeholder.sherpai2.ui.navigation
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.vector.ImageVector
*/ */
sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) { sealed class AppDestinations(val route: String, val icon: ImageVector, val label: String) {
// Core Functional Sections // Core Functional Sections
object Tour : AppDestinations(
route = "Tour",
icon = Icons.Default.PhotoLibrary,
label = "Tour"
)
object Search : AppDestinations("search", Icons.Default.Search, "Search") object Search : AppDestinations("search", Icons.Default.Search, "Search")
object Models : AppDestinations("models", Icons.Default.Layers, "Models") object Models : AppDestinations("models", Icons.Default.Layers, "Models")
object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv") object Inventory : AppDestinations("inv", Icons.Default.Inventory2, "Inv")
@@ -23,6 +28,7 @@ sealed class AppDestinations(val route: String, val icon: ImageVector, val label
// Lists used by the AppDrawerContent to render the menu sections easily // Lists used by the AppDrawerContent to render the menu sections easily
val mainDrawerItems = listOf( val mainDrawerItems = listOf(
AppDestinations.Tour,
AppDestinations.Search, AppDestinations.Search,
AppDestinations.Models, AppDestinations.Models,
AppDestinations.Inventory, AppDestinations.Inventory,

View File

@@ -1,13 +1,17 @@
// In presentation/MainScreen.kt // In presentation/MainScreen.kt
package com.placeholder.sherpai2.presentation package com.placeholder.sherpai2.presentation
import GalleryViewModel
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.placeholder.sherpai2.navigation.AppDestinations 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 import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -16,8 +20,9 @@ fun MainScreen() {
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// State to track which screen is currently visible // State to track which screen is currently visible
//var currentScreen by remember { mutableStateOf(AppDestinations.Search) } // var currentScreen by remember { mutableStateOf(AppDestinations.Search) }
var currentScreen: AppDestinations 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 provides the left sidebar UI/UX
ModalNavigationDrawer( ModalNavigationDrawer(
@@ -50,6 +55,7 @@ fun MainScreen() {
// Displays the content for the currently selected screen // Displays the content for the currently selected screen
MainContentArea( MainContentArea(
currentScreen = currentScreen, currentScreen = currentScreen,
galleryViewModel = galleryViewModel,
modifier = Modifier.padding(paddingValues) modifier = Modifier.padding(paddingValues)
) )
} }

View File

@@ -1,14 +1,14 @@
// In presentation/AppDrawerContent.kt // In presentation/AppDrawerContent.kt
package com.placeholder.sherpai2.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.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 com.placeholder.sherpai2.navigation.AppDestinations import com.placeholder.sherpai2.ui.navigation.AppDestinations
import com.placeholder.sherpai2.navigation.mainDrawerItems import com.placeholder.sherpai2.ui.navigation.mainDrawerItems
import com.placeholder.sherpai2.navigation.utilityDrawerItems import com.placeholder.sherpai2.ui.navigation.utilityDrawerItems
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable

View File

@@ -1,27 +1,38 @@
// In presentation/MainContentArea.kt // In presentation/MainContentArea.kt
package com.placeholder.sherpai2.presentation 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.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 androidx.compose.ui.unit.dp
import com.placeholder.sherpai2.navigation.AppDestinations import com.placeholder.sherpai2.ui.navigation.AppDestinations
import com.placeholder.sherpai2.ui.theme.SherpAI2Theme
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable @Composable
fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier) { fun MainContentArea(currentScreen: AppDestinations, modifier: Modifier = Modifier, galleryViewModel: GalleryViewModel = viewModel() ) {
Box( val uiState by galleryViewModel.uiState.collectAsState()
Box(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant), .background(Color.Red),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
// Swaps the UI content based on the selected screen from the drawer // Swaps the UI content based on the selected screen from the drawer
when (currentScreen) { when (currentScreen) {
AppDestinations.Search -> SimplePlaceholder("Search Screen: Find your models and data.") AppDestinations.Tour -> GalleryScreen(state = uiState, modifier = Modifier)
AppDestinations.Search -> SimplePlaceholder("Find Any Photos.")
AppDestinations.Models -> SimplePlaceholder("Models Screen: Manage your LoRA/embeddings.") AppDestinations.Models -> SimplePlaceholder("Models Screen: Manage your LoRA/embeddings.")
AppDestinations.Inventory -> SimplePlaceholder("Inventory Screen: View all collected data.") AppDestinations.Inventory -> SimplePlaceholder("Inventory Screen: View all collected data.")
AppDestinations.Train -> SimplePlaceholder("Train Screen: Start the LoRA adaptation process.") AppDestinations.Train -> SimplePlaceholder("Train Screen: Start the LoRA adaptation process.")
@@ -37,6 +48,9 @@ private fun SimplePlaceholder(text: String) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp).background(color = Color.Magenta)
) )
} }

View File

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

View File

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

View File

@@ -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()
}

View File

@@ -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")
}
}
}
}

View File

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

View File

@@ -3,4 +3,5 @@ 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
alias(libs.plugins.ksp) apply false
} }

View File

@@ -1,32 +1,62 @@
[versions] [versions]
agp = "8.13.1" # Tooling
agp = "8.7.3"
kotlin = "2.0.21" kotlin = "2.0.21"
coreKtx = "1.17.0" ksp = "2.0.21-1.0.28"
junit = "4.13.2"
junitVersion = "1.3.0" # AndroidX / Lifecycle
espressoCore = "3.7.0" coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.10.0" lifecycle = "2.8.7"
activityCompose = "1.12.1" activityCompose = "1.9.3"
composeBom = "2024.09.00" composeBom = "2024.12.01"
navigationCompose = "2.8.5"
hiltNavigationCompose = "1.2.0"
# DI
hilt = "2.52"
# Images
coil = "2.7.0"
#backend2
#Room
room = "2.6.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
# Compose BOM & UI
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" } # ADDED THIS
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" }
# Misc
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
#backend2
#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" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

View File

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