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