diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
index 5df2b2d..d7fa8b0 100644
--- a/.idea/deviceManager.xml
+++ b/.idea/deviceManager.xml
@@ -12,6 +12,7 @@
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt
index 6bda55f..5a76b26 100644
--- a/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/data/local/AppDatabase.kt
@@ -16,25 +16,30 @@ import com.placeholder.sherpai2.data.local.entity.*
*/
@Database(
entities = [
- // ===== YOUR EXISTING ENTITIES =====
+ // ===== CORE ENTITIES =====
ImageEntity::class,
TagEntity::class,
EventEntity::class,
ImageTagEntity::class,
ImageEventEntity::class,
- // ===== NEW ENTITIES =====
- PersonEntity::class, // NEW: People
- FaceModelEntity::class, // NEW: Face embeddings
- PhotoFaceTagEntity::class // NEW: Face tags
+ // ===== FACE RECOGNITION =====
+ PersonEntity::class,
+ FaceModelEntity::class,
+ PhotoFaceTagEntity::class,
+
+ // ===== COLLECTIONS =====
+ CollectionEntity::class,
+ CollectionImageEntity::class,
+ CollectionFilterEntity::class
],
- version = 5,
+ version = 6,
exportSchema = false
)
// No TypeConverters needed - embeddings stored as strings
abstract class AppDatabase : RoomDatabase() {
- // ===== YOUR EXISTING DAOs =====
+ // ===== CORE DAOs =====
abstract fun imageDao(): ImageDao
abstract fun tagDao(): TagDao
abstract fun eventDao(): EventDao
@@ -42,8 +47,11 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun imageEventDao(): ImageEventDao
abstract fun imageAggregateDao(): ImageAggregateDao
- // ===== NEW DAOs =====
- abstract fun personDao(): PersonDao // NEW: Manage people
- abstract fun faceModelDao(): FaceModelDao // NEW: Manage face embeddings
- abstract fun photoFaceTagDao(): PhotoFaceTagDao // NEW: Manage face tags
+ // ===== FACE RECOGNITION DAOs =====
+ abstract fun personDao(): PersonDao
+ abstract fun faceModelDao(): FaceModelDao
+ abstract fun photoFaceTagDao(): PhotoFaceTagDao
+
+ // ===== COLLECTIONS DAO =====
+ abstract fun collectionDao(): CollectionDao
}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/Collectiondao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/Collectiondao.kt
new file mode 100644
index 0000000..8541231
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/Collectiondao.kt
@@ -0,0 +1,216 @@
+package com.placeholder.sherpai2.data.local.dao
+
+import androidx.room.*
+import com.placeholder.sherpai2.data.local.entity.*
+import com.placeholder.sherpai2.data.local.model.CollectionWithDetails
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * CollectionDao - Manage user collections
+ */
+@Dao
+interface CollectionDao {
+
+ // ==========================================
+ // BASIC OPERATIONS
+ // ==========================================
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(collection: CollectionEntity): Long
+
+ @Update
+ suspend fun update(collection: CollectionEntity)
+
+ @Delete
+ suspend fun delete(collection: CollectionEntity)
+
+ @Query("DELETE FROM collections WHERE collectionId = :collectionId")
+ suspend fun deleteById(collectionId: String)
+
+ @Query("SELECT * FROM collections WHERE collectionId = :collectionId")
+ suspend fun getById(collectionId: String): CollectionEntity?
+
+ @Query("SELECT * FROM collections WHERE collectionId = :collectionId")
+ fun getByIdFlow(collectionId: String): Flow
+
+ // ==========================================
+ // LIST QUERIES
+ // ==========================================
+
+ /**
+ * Get all collections ordered by pinned, then by creation date
+ */
+ @Query("""
+ SELECT * FROM collections
+ ORDER BY isPinned DESC, createdAt DESC
+ """)
+ fun getAllCollections(): Flow>
+
+ @Query("""
+ SELECT * FROM collections
+ WHERE type = :type
+ ORDER BY isPinned DESC, createdAt DESC
+ """)
+ fun getCollectionsByType(type: String): Flow>
+
+ @Query("SELECT * FROM collections WHERE type = 'FAVORITE' LIMIT 1")
+ suspend fun getFavoriteCollection(): CollectionEntity?
+
+ // ==========================================
+ // COLLECTION WITH DETAILS
+ // ==========================================
+
+ /**
+ * Get collection with actual photo count
+ */
+ @Transaction
+ @Query("""
+ SELECT
+ c.*,
+ (SELECT COUNT(*)
+ FROM collection_images ci
+ WHERE ci.collectionId = c.collectionId) as actualPhotoCount
+ FROM collections c
+ WHERE c.collectionId = :collectionId
+ """)
+ fun getCollectionWithDetails(collectionId: String): Flow
+
+ // ==========================================
+ // IMAGE MANAGEMENT
+ // ==========================================
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun addImage(collectionImage: CollectionImageEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun addImages(collectionImages: List)
+
+ @Query("""
+ DELETE FROM collection_images
+ WHERE collectionId = :collectionId AND imageId = :imageId
+ """)
+ suspend fun removeImage(collectionId: String, imageId: String)
+
+ @Query("DELETE FROM collection_images WHERE collectionId = :collectionId")
+ suspend fun clearAllImages(collectionId: String)
+
+ @Query("""
+ SELECT i.* FROM images i
+ JOIN collection_images ci ON i.imageId = ci.imageId
+ WHERE ci.collectionId = :collectionId
+ ORDER BY ci.sortOrder ASC, ci.addedAt DESC
+ """)
+ fun getImagesInCollection(collectionId: String): Flow>
+
+ @Query("""
+ SELECT i.* FROM images i
+ JOIN collection_images ci ON i.imageId = ci.imageId
+ WHERE ci.collectionId = :collectionId
+ ORDER BY ci.sortOrder ASC, ci.addedAt DESC
+ LIMIT 4
+ """)
+ suspend fun getPreviewImages(collectionId: String): List
+
+ @Query("""
+ SELECT COUNT(*) FROM collection_images
+ WHERE collectionId = :collectionId
+ """)
+ suspend fun getPhotoCount(collectionId: String): Int
+
+ @Query("""
+ SELECT EXISTS(
+ SELECT 1 FROM collection_images
+ WHERE collectionId = :collectionId AND imageId = :imageId
+ )
+ """)
+ suspend fun containsImage(collectionId: String, imageId: String): Boolean
+
+ // ==========================================
+ // FILTER MANAGEMENT (for SMART collections)
+ // ==========================================
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertFilter(filter: CollectionFilterEntity)
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insertFilters(filters: List)
+
+ @Query("DELETE FROM collection_filters WHERE collectionId = :collectionId")
+ suspend fun clearFilters(collectionId: String)
+
+ @Query("""
+ SELECT * FROM collection_filters
+ WHERE collectionId = :collectionId
+ ORDER BY createdAt ASC
+ """)
+ suspend fun getFilters(collectionId: String): List
+
+ @Query("""
+ SELECT * FROM collection_filters
+ WHERE collectionId = :collectionId
+ ORDER BY createdAt ASC
+ """)
+ fun getFiltersFlow(collectionId: String): Flow>
+
+ // ==========================================
+ // STATISTICS
+ // ==========================================
+
+ @Query("SELECT COUNT(*) FROM collections")
+ suspend fun getCollectionCount(): Int
+
+ @Query("SELECT COUNT(*) FROM collections WHERE type = 'SMART'")
+ suspend fun getSmartCollectionCount(): Int
+
+ @Query("SELECT COUNT(*) FROM collections WHERE type = 'STATIC'")
+ suspend fun getStaticCollectionCount(): Int
+
+ @Query("""
+ SELECT SUM(photoCount) FROM collections
+ """)
+ suspend fun getTotalPhotosInCollections(): Int?
+
+ // ==========================================
+ // UPDATES
+ // ==========================================
+
+ /**
+ * Update photo count cache (call after adding/removing images)
+ */
+ @Query("""
+ UPDATE collections
+ SET photoCount = (
+ SELECT COUNT(*) FROM collection_images
+ WHERE collectionId = :collectionId
+ ),
+ updatedAt = :updatedAt
+ WHERE collectionId = :collectionId
+ """)
+ suspend fun updatePhotoCount(collectionId: String, updatedAt: Long)
+
+ @Query("""
+ UPDATE collections
+ SET coverImageUri = :imageUri, updatedAt = :updatedAt
+ WHERE collectionId = :collectionId
+ """)
+ suspend fun updateCoverImage(collectionId: String, imageUri: String?, updatedAt: Long)
+
+ @Query("""
+ UPDATE collections
+ SET isPinned = :isPinned, updatedAt = :updatedAt
+ WHERE collectionId = :collectionId
+ """)
+ suspend fun updatePinned(collectionId: String, isPinned: Boolean, updatedAt: Long)
+
+ @Query("""
+ UPDATE collections
+ SET name = :name, description = :description, updatedAt = :updatedAt
+ WHERE collectionId = :collectionId
+ """)
+ suspend fun updateDetails(
+ collectionId: String,
+ name: String,
+ description: String?,
+ updatedAt: Long
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionentity.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionentity.kt
new file mode 100644
index 0000000..3dbc2f3
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionentity.kt
@@ -0,0 +1,107 @@
+package com.placeholder.sherpai2.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import java.util.UUID
+
+/**
+ * CollectionEntity - User-created photo collections
+ *
+ * Types:
+ * - SMART: Dynamic collection based on filters (re-evaluated)
+ * - STATIC: Fixed snapshot of photos
+ * - FAVORITE: Special favorites collection
+ */
+@Entity(
+ tableName = "collections",
+ indices = [
+ Index(value = ["name"]),
+ Index(value = ["type"]),
+ Index(value = ["createdAt"])
+ ]
+)
+data class CollectionEntity(
+ @PrimaryKey
+ val collectionId: String,
+
+ val name: String,
+ val description: String?,
+
+ /**
+ * Cover image (auto-selected or user-chosen)
+ */
+ val coverImageUri: String?,
+
+ /**
+ * SMART | STATIC | FAVORITE
+ */
+ val type: String,
+
+ /**
+ * Cached photo count for performance
+ */
+ val photoCount: Int,
+
+ val createdAt: Long,
+ val updatedAt: Long,
+
+ /**
+ * Pinned to top of collections list
+ */
+ val isPinned: Boolean
+) {
+ companion object {
+ fun createSmart(
+ name: String,
+ description: String? = null
+ ): CollectionEntity {
+ val now = System.currentTimeMillis()
+ return CollectionEntity(
+ collectionId = UUID.randomUUID().toString(),
+ name = name,
+ description = description,
+ coverImageUri = null,
+ type = "SMART",
+ photoCount = 0,
+ createdAt = now,
+ updatedAt = now,
+ isPinned = false
+ )
+ }
+
+ fun createStatic(
+ name: String,
+ description: String? = null,
+ photoCount: Int = 0
+ ): CollectionEntity {
+ val now = System.currentTimeMillis()
+ return CollectionEntity(
+ collectionId = UUID.randomUUID().toString(),
+ name = name,
+ description = description,
+ coverImageUri = null,
+ type = "STATIC",
+ photoCount = photoCount,
+ createdAt = now,
+ updatedAt = now,
+ isPinned = false
+ )
+ }
+
+ fun createFavorite(): CollectionEntity {
+ val now = System.currentTimeMillis()
+ return CollectionEntity(
+ collectionId = "favorites",
+ name = "Favorites",
+ description = "Your favorite photos",
+ coverImageUri = null,
+ type = "FAVORITE",
+ photoCount = 0,
+ createdAt = now,
+ updatedAt = now,
+ isPinned = true
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionfilterentity.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionfilterentity.kt
new file mode 100644
index 0000000..2bb5fd5
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionfilterentity.kt
@@ -0,0 +1,70 @@
+package com.placeholder.sherpai2.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import java.util.UUID
+
+/**
+ * CollectionFilterEntity - Filters for SMART collections
+ *
+ * Filter Types:
+ * - PERSON_INCLUDE: Person must be in photo
+ * - PERSON_EXCLUDE: Person must NOT be in photo
+ * - TAG_INCLUDE: Tag must be present
+ * - TAG_EXCLUDE: Tag must NOT be present
+ * - DATE_RANGE: Date filter (TODAY, THIS_WEEK, etc)
+ */
+@Entity(
+ tableName = "collection_filters",
+ foreignKeys = [
+ ForeignKey(
+ entity = CollectionEntity::class,
+ parentColumns = ["collectionId"],
+ childColumns = ["collectionId"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [
+ Index("collectionId"),
+ Index("filterType")
+ ]
+)
+data class CollectionFilterEntity(
+ @PrimaryKey
+ val filterId: String,
+
+ val collectionId: String,
+
+ /**
+ * PERSON_INCLUDE | PERSON_EXCLUDE | TAG_INCLUDE | TAG_EXCLUDE | DATE_RANGE
+ */
+ val filterType: String,
+
+ /**
+ * The filter value:
+ * - For PERSON_*: personId
+ * - For TAG_*: tag value
+ * - For DATE_RANGE: "TODAY", "THIS_WEEK", etc
+ */
+ val filterValue: String,
+
+ val createdAt: Long
+) {
+ companion object {
+ fun create(
+ collectionId: String,
+ filterType: String,
+ filterValue: String
+ ): CollectionFilterEntity {
+ return CollectionFilterEntity(
+ filterId = UUID.randomUUID().toString(),
+ collectionId = collectionId,
+ filterType = filterType,
+ filterValue = filterValue,
+ createdAt = System.currentTimeMillis()
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionimageentity.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionimageentity.kt
new file mode 100644
index 0000000..33c0098
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/data/local/entity/Collectionimageentity.kt
@@ -0,0 +1,50 @@
+package com.placeholder.sherpai2.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+
+/**
+ * CollectionImageEntity - Join table linking collections to images
+ *
+ * Supports:
+ * - Custom sort order
+ * - Timestamp when added
+ */
+@Entity(
+ tableName = "collection_images",
+ primaryKeys = ["collectionId", "imageId"],
+ foreignKeys = [
+ ForeignKey(
+ entity = CollectionEntity::class,
+ parentColumns = ["collectionId"],
+ childColumns = ["collectionId"],
+ onDelete = ForeignKey.CASCADE
+ ),
+ ForeignKey(
+ entity = ImageEntity::class,
+ parentColumns = ["imageId"],
+ childColumns = ["imageId"],
+ onDelete = ForeignKey.CASCADE
+ )
+ ],
+ indices = [
+ Index("collectionId"),
+ Index("imageId"),
+ Index("addedAt")
+ ]
+)
+data class CollectionImageEntity(
+ val collectionId: String,
+ val imageId: String,
+
+ /**
+ * When this image was added to the collection
+ */
+ val addedAt: Long,
+
+ /**
+ * Custom sort order (lower = earlier)
+ */
+ val sortOrder: Int
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/model/Collectionwithdetails.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/model/Collectionwithdetails.kt
new file mode 100644
index 0000000..ab0dcd5
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/data/local/model/Collectionwithdetails.kt
@@ -0,0 +1,18 @@
+package com.placeholder.sherpai2.data.local.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Embedded
+import com.placeholder.sherpai2.data.local.entity.CollectionEntity
+
+/**
+ * CollectionWithDetails - Collection with computed preview data
+ *
+ * Room maps this directly from query results
+ */
+data class CollectionWithDetails(
+ @Embedded
+ val collection: CollectionEntity,
+
+ @ColumnInfo(name = "actualPhotoCount")
+ val actualPhotoCount: Int
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/data/repository/Collectionrepository.kt b/app/src/main/java/com/placeholder/sherpai2/data/repository/Collectionrepository.kt
new file mode 100644
index 0000000..d6c97d8
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/data/repository/Collectionrepository.kt
@@ -0,0 +1,327 @@
+package com.placeholder.sherpai2.data.repository
+
+import com.placeholder.sherpai2.data.local.dao.CollectionDao
+import com.placeholder.sherpai2.data.local.dao.ImageAggregateDao
+import com.placeholder.sherpai2.data.local.entity.*
+import com.placeholder.sherpai2.data.local.model.CollectionWithDetails
+import com.placeholder.sherpai2.ui.search.DateRange
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * CollectionRepository - Business logic for collections
+ *
+ * Handles:
+ * - Creating smart/static collections
+ * - Evaluating smart collection filters
+ * - Managing photos in collections
+ * - Export functionality
+ */
+@Singleton
+class CollectionRepository @Inject constructor(
+ private val collectionDao: CollectionDao,
+ private val imageAggregateDao: ImageAggregateDao
+) {
+
+ // ==========================================
+ // COLLECTION OPERATIONS
+ // ==========================================
+
+ suspend fun createSmartCollection(
+ name: String,
+ description: String?,
+ includedPeople: Set,
+ excludedPeople: Set,
+ includedTags: Set,
+ excludedTags: Set,
+ dateRange: DateRange
+ ): String {
+ // Create collection
+ val collection = CollectionEntity.createSmart(name, description)
+ collectionDao.insert(collection)
+
+ // Save filters
+ val filters = mutableListOf()
+
+ includedPeople.forEach {
+ filters.add(
+ CollectionFilterEntity.create(
+ collection.collectionId,
+ "PERSON_INCLUDE",
+ it
+ )
+ )
+ }
+
+ excludedPeople.forEach {
+ filters.add(
+ CollectionFilterEntity.create(
+ collection.collectionId,
+ "PERSON_EXCLUDE",
+ it
+ )
+ )
+ }
+
+ includedTags.forEach {
+ filters.add(
+ CollectionFilterEntity.create(
+ collection.collectionId,
+ "TAG_INCLUDE",
+ it
+ )
+ )
+ }
+
+ excludedTags.forEach {
+ filters.add(
+ CollectionFilterEntity.create(
+ collection.collectionId,
+ "TAG_EXCLUDE",
+ it
+ )
+ )
+ }
+
+ if (dateRange != DateRange.ALL_TIME) {
+ filters.add(
+ CollectionFilterEntity.create(
+ collection.collectionId,
+ "DATE_RANGE",
+ dateRange.name
+ )
+ )
+ }
+
+ if (filters.isNotEmpty()) {
+ collectionDao.insertFilters(filters)
+ }
+
+ // Evaluate and populate
+ evaluateSmartCollection(collection.collectionId)
+
+ return collection.collectionId
+ }
+
+ suspend fun createStaticCollection(
+ name: String,
+ description: String?,
+ imageIds: List
+ ): String {
+ val collection = CollectionEntity.createStatic(name, description, imageIds.size)
+ collectionDao.insert(collection)
+
+ // Add images
+ val now = System.currentTimeMillis()
+ val collectionImages = imageIds.mapIndexed { index, imageId ->
+ CollectionImageEntity(
+ collectionId = collection.collectionId,
+ imageId = imageId,
+ addedAt = now,
+ sortOrder = index
+ )
+ }
+
+ collectionDao.addImages(collectionImages)
+ collectionDao.updatePhotoCount(collection.collectionId, now)
+
+ // Set cover image to first image
+ if (imageIds.isNotEmpty()) {
+ val firstImage = imageAggregateDao.observeAllImagesWithEverything()
+ .first()
+ .find { it.image.imageId == imageIds.first() }
+
+ if (firstImage != null) {
+ collectionDao.updateCoverImage(collection.collectionId, firstImage.image.imageUri, now)
+ }
+ }
+
+ return collection.collectionId
+ }
+
+ suspend fun deleteCollection(collectionId: String) {
+ collectionDao.deleteById(collectionId)
+ }
+
+ fun getAllCollections(): Flow> {
+ return collectionDao.getAllCollections()
+ }
+
+ fun getCollection(collectionId: String): Flow {
+ return collectionDao.getByIdFlow(collectionId)
+ }
+
+ fun getCollectionWithDetails(collectionId: String): Flow {
+ return collectionDao.getCollectionWithDetails(collectionId)
+ }
+
+ // ==========================================
+ // IMAGE MANAGEMENT
+ // ==========================================
+
+ suspend fun addImageToCollection(collectionId: String, imageId: String) {
+ val now = System.currentTimeMillis()
+ val count = collectionDao.getPhotoCount(collectionId)
+
+ collectionDao.addImage(
+ CollectionImageEntity(
+ collectionId = collectionId,
+ imageId = imageId,
+ addedAt = now,
+ sortOrder = count
+ )
+ )
+
+ collectionDao.updatePhotoCount(collectionId, now)
+
+ // Update cover image if this is the first photo
+ if (count == 0) {
+ val images = collectionDao.getPreviewImages(collectionId)
+ if (images.isNotEmpty()) {
+ collectionDao.updateCoverImage(collectionId, images.first().imageUri, now)
+ }
+ }
+ }
+
+ suspend fun removeImageFromCollection(collectionId: String, imageId: String) {
+ collectionDao.removeImage(collectionId, imageId)
+ collectionDao.updatePhotoCount(collectionId, System.currentTimeMillis())
+ }
+
+ suspend fun toggleFavorite(imageId: String) {
+ val favCollection = collectionDao.getFavoriteCollection()
+ ?: run {
+ // Create favorites collection if it doesn't exist
+ val fav = CollectionEntity.createFavorite()
+ collectionDao.insert(fav)
+ fav
+ }
+
+ val isFavorite = collectionDao.containsImage(favCollection.collectionId, imageId)
+
+ if (isFavorite) {
+ removeImageFromCollection(favCollection.collectionId, imageId)
+ } else {
+ addImageToCollection(favCollection.collectionId, imageId)
+ }
+ }
+
+ suspend fun isFavorite(imageId: String): Boolean {
+ val favCollection = collectionDao.getFavoriteCollection() ?: return false
+ return collectionDao.containsImage(favCollection.collectionId, imageId)
+ }
+
+ fun getImagesInCollection(collectionId: String): Flow> {
+ return collectionDao.getImagesInCollection(collectionId)
+ }
+
+ // ==========================================
+ // SMART COLLECTION EVALUATION
+ // ==========================================
+
+ /**
+ * Re-evaluate a SMART collection's filters and update its images
+ */
+ suspend fun evaluateSmartCollection(collectionId: String) {
+ val collection = collectionDao.getById(collectionId) ?: return
+ if (collection.type != "SMART") return
+
+ val filters = collectionDao.getFilters(collectionId)
+ if (filters.isEmpty()) return
+
+ // Get all images
+ val allImages = imageAggregateDao.observeAllImagesWithEverything().first()
+
+ // Parse filters
+ val includedPeople = filters
+ .filter { it.filterType == "PERSON_INCLUDE" }
+ .map { it.filterValue }
+ .toSet()
+
+ val excludedPeople = filters
+ .filter { it.filterType == "PERSON_EXCLUDE" }
+ .map { it.filterValue }
+ .toSet()
+
+ val includedTags = filters
+ .filter { it.filterType == "TAG_INCLUDE" }
+ .map { it.filterValue }
+ .toSet()
+
+ val excludedTags = filters
+ .filter { it.filterType == "TAG_EXCLUDE" }
+ .map { it.filterValue }
+ .toSet()
+
+ // Filter images (same logic as SearchViewModel)
+ val matchingImages = allImages.filter { imageWithEverything ->
+ // TODO: Apply same Boolean logic as SearchViewModel
+ // For now, simple tag matching
+ val imageTags = imageWithEverything.tags.map { it.value }.toSet()
+
+ val hasIncludedTags = includedTags.isEmpty() || includedTags.all { it in imageTags }
+ val hasNoExcludedTags = excludedTags.isEmpty() || excludedTags.none { it in imageTags }
+
+ hasIncludedTags && hasNoExcludedTags
+ }.map { it.image.imageId }
+
+ // Update collection
+ collectionDao.clearAllImages(collectionId)
+
+ val now = System.currentTimeMillis()
+ val collectionImages = matchingImages.mapIndexed { index, imageId ->
+ CollectionImageEntity(
+ collectionId = collectionId,
+ imageId = imageId,
+ addedAt = now,
+ sortOrder = index
+ )
+ }
+
+ if (collectionImages.isNotEmpty()) {
+ collectionDao.addImages(collectionImages)
+
+ // Set cover image to first image
+ val firstImageId = matchingImages.first()
+ val firstImage = allImages.find { it.image.imageId == firstImageId }
+ if (firstImage != null) {
+ collectionDao.updateCoverImage(collectionId, firstImage.image.imageUri, now)
+ }
+ }
+
+ collectionDao.updatePhotoCount(collectionId, now)
+ }
+
+ /**
+ * Re-evaluate all SMART collections
+ */
+ suspend fun evaluateAllSmartCollections() {
+ val collections = collectionDao.getCollectionsByType("SMART").first()
+ collections.forEach { collection ->
+ evaluateSmartCollection(collection.collectionId)
+ }
+ }
+
+ // ==========================================
+ // UPDATES
+ // ==========================================
+
+ suspend fun updateCollectionDetails(
+ collectionId: String,
+ name: String,
+ description: String?
+ ) {
+ collectionDao.updateDetails(collectionId, name, description, System.currentTimeMillis())
+ }
+
+ suspend fun togglePinned(collectionId: String) {
+ val collection = collectionDao.getById(collectionId) ?: return
+ collectionDao.updatePinned(
+ collectionId,
+ !collection.isPinned,
+ System.currentTimeMillis()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt b/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt
index ea9399a..c7837e0 100644
--- a/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/di/DatabaseModule.kt
@@ -76,4 +76,9 @@ object DatabaseModule {
@Provides
fun providePhotoFaceTagDao(db: AppDatabase): PhotoFaceTagDao =
db.photoFaceTagDao()
+
+ // ===== COLLECTIONS DAOs =====
+ @Provides
+ fun provideCollectionDao(db: AppDatabase): CollectionDao =
+ db.collectionDao()
}
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/collections/Collectionsscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/collections/Collectionsscreen.kt
new file mode 100644
index 0000000..8db56d9
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/collections/Collectionsscreen.kt
@@ -0,0 +1,389 @@
+package com.placeholder.sherpai2.ui.collections
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.grid.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import coil.compose.AsyncImage
+
+/**
+ * CollectionsScreen - Main collections list
+ *
+ * Features:
+ * - Grid of collection cards
+ * - Create new collection button
+ * - Filter by type (all, smart, static)
+ * - Collection details on click
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CollectionsScreen(
+ onCollectionClick: (String) -> Unit,
+ onCreateClick: () -> Unit,
+ viewModel: CollectionsViewModel = hiltViewModel()
+) {
+ val collections by viewModel.collections.collectAsStateWithLifecycle()
+ val creationState by viewModel.creationState.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ "Collections",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ viewModel.getCollectionSummary(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { viewModel.refreshSmartCollections() }) {
+ Icon(Icons.Default.Refresh, "Refresh smart collections")
+ }
+ }
+ )
+ },
+ floatingActionButton = {
+ ExtendedFloatingActionButton(
+ onClick = onCreateClick,
+ icon = { Icon(Icons.Default.Add, null) },
+ text = { Text("New Collection") }
+ )
+ }
+ ) { paddingValues ->
+ if (collections.isEmpty()) {
+ EmptyState(
+ onCreateClick = onCreateClick,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ )
+ } else {
+ LazyVerticalGrid(
+ columns = GridCells.Adaptive(160.dp),
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentPadding = PaddingValues(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(
+ items = collections,
+ key = { it.collectionId }
+ ) { collection ->
+ CollectionCard(
+ collection = collection,
+ onClick = { onCollectionClick(collection.collectionId) },
+ onPinToggle = { viewModel.togglePinned(collection.collectionId) },
+ onDelete = { viewModel.deleteCollection(collection.collectionId) }
+ )
+ }
+ }
+ }
+ }
+
+ // Creation dialog (shown from SearchScreen or other places)
+ when (val state = creationState) {
+ is CreationState.SmartFromSearch -> {
+ CreateCollectionDialog(
+ title = "Smart Collection",
+ subtitle = "${state.photoCount} photos matching filters",
+ onConfirm = { name, description ->
+ viewModel.createSmartCollection(name, description)
+ },
+ onDismiss = { viewModel.cancelCreation() }
+ )
+ }
+ is CreationState.StaticFromImages -> {
+ CreateCollectionDialog(
+ title = "Static Collection",
+ subtitle = "${state.photoCount} photos selected",
+ onConfirm = { name, description ->
+ viewModel.createStaticCollection(name, description)
+ },
+ onDismiss = { viewModel.cancelCreation() }
+ )
+ }
+ CreationState.None -> { /* No dialog */ }
+ }
+}
+
+@Composable
+private fun CollectionCard(
+ collection: com.placeholder.sherpai2.data.local.entity.CollectionEntity,
+ onClick: () -> Unit,
+ onPinToggle: () -> Unit,
+ onDelete: () -> Unit
+) {
+ var showMenu by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(0.75f)
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Cover image or placeholder
+ if (collection.coverImageUri != null) {
+ AsyncImage(
+ model = collection.coverImageUri,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = androidx.compose.ui.layout.ContentScale.Crop
+ )
+ } else {
+ // Placeholder
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surfaceVariant
+ ) {
+ Icon(
+ Icons.Default.Photo,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(48.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+ )
+ }
+ }
+
+ // Gradient overlay for text
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter),
+ color = Color.Black.copy(alpha = 0.6f)
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ collection.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+
+ Box {
+ IconButton(
+ onClick = { showMenu = true },
+ modifier = Modifier.size(24.dp)
+ ) {
+ Icon(
+ Icons.Default.MoreVert,
+ null,
+ tint = Color.White
+ )
+ }
+
+ DropdownMenu(
+ expanded = showMenu,
+ onDismissRequest = { showMenu = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text(if (collection.isPinned) "Unpin" else "Pin") },
+ onClick = {
+ onPinToggle()
+ showMenu = false
+ },
+ leadingIcon = {
+ Icon(
+ if (collection.isPinned) Icons.Default.PushPin else Icons.Default.PushPin,
+ null
+ )
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Delete") },
+ onClick = {
+ onDelete()
+ showMenu = false
+ },
+ leadingIcon = {
+ Icon(Icons.Default.Delete, null)
+ }
+ )
+ }
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Type badge
+ Surface(
+ color = when (collection.type) {
+ "SMART" -> Color(0xFF2196F3)
+ "FAVORITE" -> Color(0xFFF44336)
+ else -> Color(0xFF4CAF50)
+ }.copy(alpha = 0.9f),
+ shape = RoundedCornerShape(4.dp)
+ ) {
+ Text(
+ when (collection.type) {
+ "SMART" -> "Smart"
+ "FAVORITE" -> "Fav"
+ else -> "Static"
+ },
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
+ style = MaterialTheme.typography.labelSmall,
+ color = Color.White
+ )
+ }
+
+ // Photo count
+ Text(
+ "${collection.photoCount} photos",
+ style = MaterialTheme.typography.bodySmall,
+ color = Color.White.copy(alpha = 0.9f)
+ )
+ }
+ }
+ }
+
+ // Pinned indicator
+ if (collection.isPinned) {
+ Icon(
+ Icons.Default.PushPin,
+ contentDescription = "Pinned",
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ .size(20.dp),
+ tint = Color.White
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyState(
+ onCreateClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Icon(
+ Icons.Default.Collections,
+ contentDescription = null,
+ modifier = Modifier.size(72.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ Text(
+ "No Collections Yet",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold
+ )
+ Text(
+ "Create collections from searches or manually select photos",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Button(onClick = onCreateClick) {
+ Icon(Icons.Default.Add, null, Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text("Create Collection")
+ }
+ }
+ }
+}
+
+@Composable
+private fun CreateCollectionDialog(
+ title: String,
+ subtitle: String,
+ onConfirm: (name: String, description: String?) -> Unit,
+ onDismiss: () -> Unit
+) {
+ var name by remember { mutableStateOf("") }
+ var description by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ icon = { Icon(Icons.Default.Collections, null) },
+ title = {
+ Column {
+ Text(title)
+ Text(
+ subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ label = { Text("Collection Name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
+ label = { Text("Description (optional)") },
+ maxLines = 3,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = {
+ if (name.isNotBlank()) {
+ onConfirm(name.trim(), description.trim().ifBlank { null })
+ }
+ },
+ enabled = name.isNotBlank()
+ ) {
+ Text("Create")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/collections/Collectionsviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/collections/Collectionsviewmodel.kt
new file mode 100644
index 0000000..9834263
--- /dev/null
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/collections/Collectionsviewmodel.kt
@@ -0,0 +1,159 @@
+package com.placeholder.sherpai2.ui.collections
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.placeholder.sherpai2.data.local.entity.CollectionEntity
+import com.placeholder.sherpai2.data.repository.CollectionRepository
+import com.placeholder.sherpai2.ui.search.DateRange
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+/**
+ * CollectionsViewModel - Manages collections list and creation
+ */
+@HiltViewModel
+class CollectionsViewModel @Inject constructor(
+ private val collectionRepository: CollectionRepository
+) : ViewModel() {
+
+ // All collections
+ val collections: StateFlow> = collectionRepository
+ .getAllCollections()
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5000),
+ initialValue = emptyList()
+ )
+
+ // UI state for creation dialog
+ private val _creationState = MutableStateFlow(CreationState.None)
+ val creationState: StateFlow = _creationState.asStateFlow()
+
+ // ==========================================
+ // COLLECTION CREATION
+ // ==========================================
+
+ fun startSmartCollectionFromSearch(
+ includedPeople: Set,
+ excludedPeople: Set,
+ includedTags: Set,
+ excludedTags: Set,
+ dateRange: DateRange,
+ photoCount: Int
+ ) {
+ _creationState.value = CreationState.SmartFromSearch(
+ includedPeople = includedPeople,
+ excludedPeople = excludedPeople,
+ includedTags = includedTags,
+ excludedTags = excludedTags,
+ dateRange = dateRange,
+ photoCount = photoCount
+ )
+ }
+
+ fun startStaticCollectionFromImages(imageIds: List) {
+ _creationState.value = CreationState.StaticFromImages(
+ imageIds = imageIds,
+ photoCount = imageIds.size
+ )
+ }
+
+ fun cancelCreation() {
+ _creationState.value = CreationState.None
+ }
+
+ fun createSmartCollection(name: String, description: String?) {
+ val state = _creationState.value as? CreationState.SmartFromSearch ?: return
+
+ viewModelScope.launch {
+ collectionRepository.createSmartCollection(
+ name = name,
+ description = description,
+ includedPeople = state.includedPeople,
+ excludedPeople = state.excludedPeople,
+ includedTags = state.includedTags,
+ excludedTags = state.excludedTags,
+ dateRange = state.dateRange
+ )
+
+ _creationState.value = CreationState.None
+ }
+ }
+
+ fun createStaticCollection(name: String, description: String?) {
+ val state = _creationState.value as? CreationState.StaticFromImages ?: return
+
+ viewModelScope.launch {
+ collectionRepository.createStaticCollection(
+ name = name,
+ description = description,
+ imageIds = state.imageIds
+ )
+
+ _creationState.value = CreationState.None
+ }
+ }
+
+ // ==========================================
+ // COLLECTION MANAGEMENT
+ // ==========================================
+
+ fun deleteCollection(collectionId: String) {
+ viewModelScope.launch {
+ collectionRepository.deleteCollection(collectionId)
+ }
+ }
+
+ fun togglePinned(collectionId: String) {
+ viewModelScope.launch {
+ collectionRepository.togglePinned(collectionId)
+ }
+ }
+
+ fun refreshSmartCollections() {
+ viewModelScope.launch {
+ collectionRepository.evaluateAllSmartCollections()
+ }
+ }
+
+ // ==========================================
+ // STATISTICS
+ // ==========================================
+
+ fun getCollectionSummary(): String {
+ val count = collections.value.size
+ val smartCount = collections.value.count { it.type == "SMART" }
+ val staticCount = collections.value.count { it.type == "STATIC" }
+
+ return when {
+ count == 0 -> "No collections yet"
+ smartCount > 0 && staticCount > 0 -> "$smartCount smart • $staticCount static"
+ smartCount > 0 -> "$smartCount smart collections"
+ staticCount > 0 -> "$staticCount static collections"
+ else -> "$count collections"
+ }
+ }
+}
+
+/**
+ * Creation state for dialogs
+ */
+sealed class CreationState {
+ object None : CreationState()
+
+ data class SmartFromSearch(
+ val includedPeople: Set,
+ val excludedPeople: Set,
+ val includedTags: Set,
+ val excludedTags: Set,
+ val dateRange: DateRange,
+ val photoCount: Int
+ ) : CreationState()
+
+ data class StaticFromImages(
+ val imageIds: List,
+ val photoCount: Int
+ ) : CreationState()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt
index eaf1eb8..9e3ad00 100644
--- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppDestinations.kt
@@ -40,6 +40,13 @@ sealed class AppDestinations(
description = "Browse smart albums"
)
+ data object Collections : AppDestinations(
+ route = AppRoutes.COLLECTIONS,
+ icon = Icons.Default.Collections,
+ label = "Collections",
+ description = "Your photo collections"
+ )
+
// ImageDetail is not in draw er (internal navigation only)
// ==================
@@ -104,7 +111,8 @@ sealed class AppDestinations(
// Photo browsing section
val photoDestinations = listOf(
AppDestinations.Search,
- AppDestinations.Explore
+ AppDestinations.Explore,
+ AppDestinations.Collections
)
// Face recognition section
@@ -136,6 +144,7 @@ fun getDestinationByRoute(route: String?): AppDestinations? {
return when (route) {
AppRoutes.SEARCH -> AppDestinations.Search
AppRoutes.EXPLORE -> AppDestinations.Explore
+ AppRoutes.COLLECTIONS -> AppDestinations.Collections
AppRoutes.INVENTORY -> AppDestinations.Inventory
AppRoutes.TRAIN -> AppDestinations.Train
AppRoutes.MODELS -> AppDestinations.Models
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt
index 0c63000..cc16ff9 100644
--- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppNavHost.kt
@@ -16,6 +16,8 @@ import androidx.navigation.navArgument
import com.placeholder.sherpai2.ui.devscreens.DummyScreen
import com.placeholder.sherpai2.ui.album.AlbumViewScreen
import com.placeholder.sherpai2.ui.album.AlbumViewModel
+import com.placeholder.sherpai2.ui.collections.CollectionsScreen
+import com.placeholder.sherpai2.ui.collections.CollectionsViewModel
import com.placeholder.sherpai2.ui.explore.ExploreScreen
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen
@@ -59,6 +61,7 @@ fun AppNavHost(
*/
composable(AppRoutes.SEARCH) {
val searchViewModel: SearchViewModel = hiltViewModel()
+ val collectionsViewModel: CollectionsViewModel = hiltViewModel()
SearchScreen(
searchViewModel = searchViewModel,
@@ -71,6 +74,16 @@ fun AppNavHost(
},
onAlbumClick = { tagValue ->
navController.navigate("album/tag/$tagValue")
+ },
+ onSaveToCollection = { includedPeople, excludedPeople, includedTags, excludedTags, dateRange, photoCount ->
+ collectionsViewModel.startSmartCollectionFromSearch(
+ includedPeople = includedPeople,
+ excludedPeople = excludedPeople,
+ includedTags = includedTags,
+ excludedTags = excludedTags,
+ dateRange = dateRange,
+ photoCount = photoCount
+ )
}
)
}
@@ -86,6 +99,25 @@ fun AppNavHost(
)
}
+ /**
+ * COLLECTIONS SCREEN
+ */
+ composable(AppRoutes.COLLECTIONS) {
+ val collectionsViewModel: CollectionsViewModel = hiltViewModel()
+
+ CollectionsScreen(
+ viewModel = collectionsViewModel,
+ onCollectionClick = { collectionId ->
+ navController.navigate("album/collection/$collectionId")
+ },
+ onCreateClick = {
+ // For now, navigate to search to create from filters
+ // TODO: Add collection creation dialog
+ navController.navigate(AppRoutes.SEARCH)
+ }
+ )
+ }
+
/**
* IMAGE DETAIL SCREEN - UPDATED: Receives image list for navigation
*/
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt
index 02fc9a6..fe301a4 100644
--- a/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/navigation/AppRoutes.kt
@@ -37,4 +37,7 @@ object AppRoutes {
// Album view
const val ALBUM_VIEW = "album/{albumType}/{albumId}"
fun albumRoute(albumType: String, albumId: String) = "album/$albumType/$albumId"
+
+ //Collections
+ const val COLLECTIONS = "collections"
}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt
index c012ac4..6c47a94 100644
--- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt
@@ -18,7 +18,11 @@ import androidx.compose.material.icons.filled.*
import com.placeholder.sherpai2.ui.navigation.AppRoutes
/**
- * SLIMMED DOWN AppDrawer - 280dp width, inline logo, cleaner sections
+ * CLEAN & COMPACT Drawer
+ * - 280dp width (not 300dp)
+ * - Icon + SherpAI inline (not stacked)
+ * - NO subtitles (clean single-line items)
+ * - Terrain icon (mountain theme)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -27,12 +31,12 @@ fun AppDrawerContent(
onDestinationClicked: (String) -> Unit
) {
ModalDrawerSheet(
- modifier = Modifier.width(280.dp), // SLIMMER (was 300dp)
+ modifier = Modifier.width(280.dp), // Narrower!
drawerContainerColor = MaterialTheme.colorScheme.surface
) {
Column(modifier = Modifier.fillMaxSize()) {
- // ===== COMPACT HEADER - Icon + Text Inline =====
+ // ===== COMPACT INLINE HEADER =====
Box(
modifier = Modifier
.fillMaxWidth()
@@ -44,22 +48,22 @@ fun AppDrawerContent(
)
)
)
- .padding(20.dp) // Reduced padding
+ .padding(20.dp) // Tighter padding
) {
Row(
- horizontalArrangement = Arrangement.spacedBy(12.dp),
- verticalAlignment = Alignment.CenterVertically
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- // App icon - smaller
+ // Icon - TERRAIN (mountain theme!)
Surface(
- modifier = Modifier.size(48.dp), // Smaller (was 56dp)
- shape = RoundedCornerShape(14.dp),
+ modifier = Modifier.size(48.dp), // Smaller
+ shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
shadowElevation = 4.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
- Icons.Default.Face,
+ Icons.Default.Terrain, // Mountain icon!
contentDescription = null,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.onPrimary
@@ -67,33 +71,32 @@ fun AppDrawerContent(
}
}
- // Text next to icon
- Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ // Text INLINE with icon
+ Column {
Text(
"SherpAI",
- style = MaterialTheme.typography.titleLarge, // Smaller (was headlineMedium)
+ style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
-
Text(
- "Face Recognition System",
- style = MaterialTheme.typography.bodySmall, // Smaller
+ "Face Recognition",
+ style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
- Spacer(modifier = Modifier.height(4.dp)) // Reduced spacing
+ Spacer(modifier = Modifier.height(8.dp))
- // ===== NAVIGATION SECTIONS =====
+ // ===== NAVIGATION ITEMS - COMPACT =====
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
- .padding(horizontal = 8.dp), // Reduced padding
- verticalArrangement = Arrangement.spacedBy(2.dp) // Tighter spacing
+ .padding(horizontal = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Photos Section
@@ -101,7 +104,8 @@ fun AppDrawerContent(
val photoItems = listOf(
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search),
- DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore)
+ DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore),
+ DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections)
)
photoItems.forEach { item ->
@@ -112,14 +116,14 @@ fun AppDrawerContent(
)
}
- Spacer(modifier = Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(8.dp))
// Face Recognition Section
DrawerSection(title = "Face Recognition")
val faceItems = listOf(
DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face),
- DrawerItem(AppRoutes.TRAIN, "Train New", Icons.Default.ModelTraining),
+ DrawerItem(AppRoutes.TRAIN, "Create Person", Icons.Default.ModelTraining),
DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy)
)
@@ -131,7 +135,7 @@ fun AppDrawerContent(
)
}
- Spacer(modifier = Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(8.dp))
// Organization Section
DrawerSection(title = "Organization")
@@ -153,7 +157,7 @@ fun AppDrawerContent(
// Settings at bottom
HorizontalDivider(
- modifier = Modifier.padding(vertical = 6.dp),
+ modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
@@ -167,28 +171,28 @@ fun AppDrawerContent(
onClick = { onDestinationClicked(AppRoutes.SETTINGS) }
)
- Spacer(modifier = Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
/**
- * Section header - more compact
+ * Section header
*/
@Composable
private fun DrawerSection(title: String) {
Text(
text = title,
- style = MaterialTheme.typography.labelSmall, // Smaller
+ style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) // Reduced padding
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
/**
- * Navigation item - cleaner, no subtitle
+ * COMPACT navigation item - NO SUBTITLES
*/
@Composable
private fun DrawerNavigationItem(
@@ -200,7 +204,7 @@ private fun DrawerNavigationItem(
label = {
Text(
text = item.label,
- style = MaterialTheme.typography.bodyMedium, // Slightly smaller
+ style = MaterialTheme.typography.bodyLarge,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal
)
},
@@ -208,14 +212,14 @@ private fun DrawerNavigationItem(
Icon(
item.icon,
contentDescription = item.label,
- modifier = Modifier.size(22.dp) // Slightly smaller
+ modifier = Modifier.size(24.dp)
)
},
selected = selected,
onClick = onClick,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
- .clip(RoundedCornerShape(10.dp)), // Slightly smaller radius
+ .clip(RoundedCornerShape(12.dp)),
colors = NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedIconColor = MaterialTheme.colorScheme.primary,
@@ -226,7 +230,7 @@ private fun DrawerNavigationItem(
}
/**
- * Simplified drawer item (no subtitle)
+ * Simple drawer item - no subtitle needed
*/
private data class DrawerItem(
val route: String,
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt
index d8ade13..0326fd3 100644
--- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt
@@ -121,49 +121,6 @@ fun MainScreen() {
actionIconContentColor = MaterialTheme.colorScheme.primary
)
)
- },
- floatingActionButton = {
- // Dynamic FAB based on screen
- AnimatedVisibility(
- visible = shouldShowFab(currentRoute),
- enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
- exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
- ) {
- when (currentRoute) {
- AppRoutes.SEARCH -> {
- ExtendedFloatingActionButton(
- onClick = { /* TODO: Advanced search */ },
- icon = {
- Icon(Icons.Default.Tune, "Advanced Search")
- },
- text = { Text("Filters") },
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
- )
- }
- AppRoutes.TAGS -> {
- FloatingActionButton(
- onClick = { /* TODO: Add new tag */ },
- containerColor = MaterialTheme.colorScheme.primaryContainer,
- contentColor = MaterialTheme.colorScheme.onPrimaryContainer
- ) {
- Icon(Icons.Default.Add, "Add Tag")
- }
- }
- AppRoutes.UTILITIES -> {
- ExtendedFloatingActionButton(
- onClick = { /* TODO: Select photos */ },
- icon = { Icon(Icons.Default.CloudUpload, "Upload") },
- text = { Text("Select Photos") },
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary
- )
- }
- else -> {
- // No FAB for other screens
- }
- }
- }
}
) { paddingValues ->
AppNavHost(
@@ -204,16 +161,4 @@ private fun getScreenSubtitle(route: String): String? {
AppRoutes.UTILITIES -> "Tools for managing collection"
else -> null
}
-}
-
-/**
- * Determine if FAB should be shown for current screen
- */
-private fun shouldShowFab(route: String): Boolean {
- return when (route) {
- AppRoutes.SEARCH,
- AppRoutes.TAGS,
- AppRoutes.UTILITIES -> true
- else -> false
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt
index f9cae35..06e3aab 100644
--- a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt
+++ b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchScreen.kt
@@ -36,7 +36,15 @@ fun SearchScreen(
modifier: Modifier = Modifier,
searchViewModel: SearchViewModel,
onImageClick: (String) -> Unit,
- onAlbumClick: ((String) -> Unit)? = null
+ onAlbumClick: ((String) -> Unit)? = null,
+ onSaveToCollection: ((
+ includedPeople: Set,
+ excludedPeople: Set,
+ includedTags: Set,
+ excludedTags: Set,
+ dateRange: DateRange,
+ photoCount: Int
+ ) -> Unit)? = null
) {
val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle()
val includedPeople by searchViewModel.includedPeople.collectAsStateWithLifecycle()
@@ -126,11 +134,37 @@ fun SearchScreen(
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold
)
- TextButton(
- onClick = { searchViewModel.clearAllFilters() },
- contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
- ) {
- Text("Clear All", style = MaterialTheme.typography.labelMedium)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Save to Collection button
+ if (onSaveToCollection != null && images.isNotEmpty()) {
+ FilledTonalButton(
+ onClick = {
+ onSaveToCollection(
+ includedPeople,
+ excludedPeople,
+ includedTags,
+ excludedTags,
+ dateRange,
+ images.size
+ )
+ },
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ Icon(
+ Icons.Default.Collections,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp)
+ )
+ Spacer(Modifier.width(4.dp))
+ Text("Save", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ TextButton(
+ onClick = { searchViewModel.clearAllFilters() },
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text("Clear All", style = MaterialTheme.typography.labelMedium)
+ }
}
}