From 2b5f761d25fd358b2d05f5ab321cc3a36dfb5d50 Mon Sep 17 00:00:00 2001 From: genki <123@1234.com> Date: Sat, 10 Jan 2026 00:08:04 -0500 Subject: [PATCH] Oh yes - Thats how we do No default params for KSP complainer fuck UI sweeps --- .../data/local/entity => }/PersonEntity | 0 .../com/placeholder/sherpai2/MainActivity.kt | 47 +- .../data/service/Autotaggingservice.kt | 380 +++++++++++ .../sherpai2/ml/Thresholdstrategy.kt | 127 ++++ .../sherpai2/ui/album/Albumviewmodel.kt | 336 ++++++++++ .../sherpai2/ui/album/Albumviewscreen.kt | 358 ++++++++++ .../sherpai2/ui/explore/Explorescreen.kt | 459 +++++++++++++ .../sherpai2/ui/explore/Exploreviewmodel.kt | 302 +++++++++ .../Personinventoryviewmodel.kt | 70 +- .../sherpai2/ui/navigation/AppDestinations.kt | 14 +- .../sherpai2/ui/navigation/AppNavHost.kt | 46 +- .../sherpai2/ui/navigation/AppRoutes.kt | 30 +- .../ui/presentation/AppDrawerContent.kt | 4 +- .../sherpai2/ui/presentation/MainScreen.kt | 4 +- .../sherpai2/ui/search/SearchScreen.kt | 559 +++++++++------- .../sherpai2/ui/search/SearchViewModel.kt | 288 +++++++- .../sherpai2/ui/tags/Tagmanagementscreen.kt | 624 ++++++++++++++++++ .../ui/tags/Tagmanagementviewmodel.kt | 398 +++++++++++ 18 files changed, 3680 insertions(+), 366 deletions(-) rename app/{src/main/java/com/placeholder/sherpai2/data/local/entity => }/PersonEntity (100%) create mode 100644 app/src/main/java/com/placeholder/sherpai2/data/service/Autotaggingservice.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ml/Thresholdstrategy.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/explore/Exploreviewmodel.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt create mode 100644 app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementviewmodel.kt diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/entity/PersonEntity b/app/PersonEntity similarity index 100% rename from app/src/main/java/com/placeholder/sherpai2/data/local/entity/PersonEntity rename to app/PersonEntity diff --git a/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt b/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt index 5152f46..9639c16 100644 --- a/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt +++ b/app/src/main/java/com/placeholder/sherpai2/MainActivity.kt @@ -10,6 +10,7 @@ import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -19,7 +20,6 @@ import com.placeholder.sherpai2.domain.repository.ImageRepository import com.placeholder.sherpai2.ui.presentation.MainScreen import com.placeholder.sherpai2.ui.theme.SherpAI2Theme import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint @@ -31,11 +31,9 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Determine storage permission based on Android version val storagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES } else { - @Suppress("DEPRECATION") Manifest.permission.READ_EXTERNAL_STORAGE } @@ -43,44 +41,53 @@ class MainActivity : ComponentActivity() { SherpAI2Theme { var hasPermission by remember { mutableStateOf( - ContextCompat.checkSelfPermission(this, storagePermission) == + ContextCompat.checkSelfPermission(this@MainActivity, storagePermission) == PackageManager.PERMISSION_GRANTED ) } - // Track ingestion completion + var isIngesting by remember { mutableStateOf(false) } var imagesIngested by remember { mutableStateOf(false) } - // Launcher for permission request val permissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> hasPermission = granted } - // Trigger ingestion once permission is granted + // Logic: Handle the flow of Permission -> Ingestion LaunchedEffect(hasPermission) { if (hasPermission) { - // Suspend until ingestion completes - imageRepository.ingestImages() - imagesIngested = true + if (!imagesIngested && !isIngesting) { + isIngesting = true + imageRepository.ingestImages() + imagesIngested = true + isIngesting = false + } } else { permissionLauncher.launch(storagePermission) } } - // Gate UI until permission granted AND ingestion completed - if (hasPermission && imagesIngested) { - MainScreen() - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text("Please grant storage permission to continue.") + // UI State Mapping + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when { + hasPermission && imagesIngested -> { + MainScreen() + } + hasPermission && isIngesting -> { + // Show a loader so you know it's working! + CircularProgressIndicator() + } + else -> { + Text("Please grant storage permission to continue.") + } } } } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/service/Autotaggingservice.kt b/app/src/main/java/com/placeholder/sherpai2/data/service/Autotaggingservice.kt new file mode 100644 index 0000000..1023c84 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/data/service/Autotaggingservice.kt @@ -0,0 +1,380 @@ +package com.placeholder.sherpai2.data.service + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import com.placeholder.sherpai2.data.local.dao.ImageTagDao +import com.placeholder.sherpai2.data.local.dao.PersonDao +import com.placeholder.sherpai2.data.local.dao.PhotoFaceTagDao +import com.placeholder.sherpai2.data.local.dao.TagDao +import com.placeholder.sherpai2.data.local.entity.ImageEntity +import com.placeholder.sherpai2.data.local.entity.ImageTagEntity +import com.placeholder.sherpai2.data.local.entity.TagEntity +import com.placeholder.sherpai2.data.repository.DetectedFace +import com.placeholder.sherpai2.util.DiagnosticLogger +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Calendar +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +/** + * AutoTaggingService - Intelligent auto-tagging system + * + * Capabilities: + * - Face-based tags (group_photo, selfie, couple) + * - Scene tags (portrait, landscape, square orientation) + * - Time tags (morning, afternoon, evening, night) + * - Quality tags (high_res, low_res) + * - Relationship tags (family, friend, colleague from PersonEntity) + * - Birthday tags (from PersonEntity DOB) + * - Indoor/Outdoor estimation (basic heuristic) + */ +@Singleton +class AutoTaggingService @Inject constructor( + @ApplicationContext private val context: Context, + private val tagDao: TagDao, + private val imageTagDao: ImageTagDao, + private val photoFaceTagDao: PhotoFaceTagDao, + private val personDao: PersonDao +) { + + // ====================== + // MAIN AUTO-TAGGING + // ====================== + + /** + * Auto-tag an image with all applicable system tags + * + * @return Number of tags applied + */ + suspend fun autoTagImage( + imageEntity: ImageEntity, + bitmap: Bitmap, + detectedFaces: List + ): Int = withContext(Dispatchers.Default) { + val tagsToApply = mutableListOf() + + // Face-count based tags + when (detectedFaces.size) { + 0 -> { /* No face tags */ } + 1 -> { + if (isSelfie(detectedFaces[0], bitmap)) { + tagsToApply.add("selfie") + } else { + tagsToApply.add("single_person") + } + } + 2 -> tagsToApply.add("couple") + in 3..5 -> tagsToApply.add("group_photo") + in 6..10 -> { + tagsToApply.add("group_photo") + tagsToApply.add("large_group") + } + else -> { + tagsToApply.add("group_photo") + tagsToApply.add("large_group") + tagsToApply.add("crowd") + } + } + + // Orientation tags + val aspectRatio = bitmap.width.toFloat() / bitmap.height.toFloat() + when { + aspectRatio > 1.3f -> tagsToApply.add("landscape") + aspectRatio < 0.77f -> tagsToApply.add("portrait") + else -> tagsToApply.add("square") + } + + // Resolution tags + val megapixels = (bitmap.width * bitmap.height) / 1_000_000f + when { + megapixels > 2.0f -> tagsToApply.add("high_res") + megapixels < 0.5f -> tagsToApply.add("low_res") + } + + // Time-based tags + val hourOfDay = getHourFromTimestamp(imageEntity.capturedAt) + tagsToApply.add(when (hourOfDay) { + in 5..10 -> "morning" + in 11..16 -> "afternoon" + in 17..20 -> "evening" + else -> "night" + }) + + // Indoor/Outdoor estimation (only if image is large enough) + if (bitmap.width >= 200 && bitmap.height >= 200) { + val isIndoor = estimateIndoorOutdoor(bitmap) + tagsToApply.add(if (isIndoor) "indoor" else "outdoor") + } + + // Apply all tags + var tagsApplied = 0 + tagsToApply.forEach { tagName -> + if (applySystemTag(imageEntity.imageId, tagName)) { + tagsApplied++ + } + } + + DiagnosticLogger.d("AutoTag: Applied $tagsApplied tags to image ${imageEntity.imageId}") + tagsApplied + } + + // ====================== + // RELATIONSHIP TAGS + // ====================== + + /** + * Tag all images with a person using their relationship tag + * + * @param personId Person to tag images for + * @return Number of tags applied + */ + suspend fun autoTagRelationshipForPerson(personId: String): Int = withContext(Dispatchers.IO) { + val person = personDao.getPersonById(personId) ?: return@withContext 0 + val relationship = person.relationship?.lowercase() ?: return@withContext 0 + + // Get face model for this person + val faceModels = photoFaceTagDao.getAllTagsForFaceModel(personId) + if (faceModels.isEmpty()) return@withContext 0 + + val imageIds = faceModels.map { it.imageId }.distinct() + + var tagsApplied = 0 + imageIds.forEach { imageId -> + if (applySystemTag(imageId, relationship)) { + tagsApplied++ + } + } + + DiagnosticLogger.i("AutoTag: Applied '$relationship' tag to $tagsApplied images for ${person.name}") + tagsApplied + } + + /** + * Tag relationships for ALL persons in database + */ + suspend fun autoTagAllRelationships(): Int = withContext(Dispatchers.IO) { + val persons = personDao.getAllPersons() + var totalTags = 0 + + persons.forEach { person -> + totalTags += autoTagRelationshipForPerson(person.id) + } + + DiagnosticLogger.i("AutoTag: Applied $totalTags relationship tags across ${persons.size} persons") + totalTags + } + + // ====================== + // BIRTHDAY TAGS + // ====================== + + /** + * Tag images near a person's birthday + * + * @param personId Person whose birthday to check + * @param daysRange Days before/after birthday to consider (default: 3) + * @return Number of tags applied + */ + suspend fun autoTagBirthdaysForPerson( + personId: String, + daysRange: Int = 3 + ): Int = withContext(Dispatchers.IO) { + val person = personDao.getPersonById(personId) ?: return@withContext 0 + val dateOfBirth = person.dateOfBirth ?: return@withContext 0 + + // Get all face tags for this person + val faceTags = photoFaceTagDao.getAllTagsForFaceModel(personId) + if (faceTags.isEmpty()) return@withContext 0 + + var tagsApplied = 0 + + faceTags.forEach { faceTag -> + // Get the image to check its timestamp + val imageId = faceTag.imageId + + // Check if image was captured near birthday + if (isNearBirthday(faceTag.detectedAt, dateOfBirth, daysRange)) { + if (applySystemTag(imageId, "birthday")) { + tagsApplied++ + } + } + } + + DiagnosticLogger.i("AutoTag: Applied 'birthday' tag to $tagsApplied images for ${person.name}") + tagsApplied + } + + /** + * Tag birthdays for ALL persons with DOB + */ + suspend fun autoTagAllBirthdays(daysRange: Int = 3): Int = withContext(Dispatchers.IO) { + val persons = personDao.getAllPersons() + var totalTags = 0 + + persons.forEach { person -> + if (person.dateOfBirth != null) { + totalTags += autoTagBirthdaysForPerson(person.id, daysRange) + } + } + + DiagnosticLogger.i("AutoTag: Applied $totalTags birthday tags") + totalTags + } + + // ====================== + // HELPER METHODS + // ====================== + + /** + * Check if an image is a selfie based on face size + */ + private fun isSelfie(face: DetectedFace, bitmap: Bitmap): Boolean { + val boundingBox = face.boundingBox + val faceArea = boundingBox.width() * boundingBox.height() + val imageArea = bitmap.width * bitmap.height + val faceRatio = faceArea.toFloat() / imageArea.toFloat() + + // Selfie = face takes up significant portion (>15% of image) + return faceRatio > 0.15f + } + + /** + * Get hour of day from timestamp (0-23) + */ + private fun getHourFromTimestamp(timestamp: Long): Int { + return Calendar.getInstance().apply { + timeInMillis = timestamp + }.get(Calendar.HOUR_OF_DAY) + } + + /** + * Check if a timestamp is near a birthday + */ + private fun isNearBirthday( + capturedTimestamp: Long, + dobTimestamp: Long, + daysRange: Int + ): Boolean { + val capturedCal = Calendar.getInstance().apply { timeInMillis = capturedTimestamp } + val dobCal = Calendar.getInstance().apply { timeInMillis = dobTimestamp } + + val capturedMonth = capturedCal.get(Calendar.MONTH) + val capturedDay = capturedCal.get(Calendar.DAY_OF_MONTH) + val dobMonth = dobCal.get(Calendar.MONTH) + val dobDay = dobCal.get(Calendar.DAY_OF_MONTH) + + if (capturedMonth == dobMonth) { + return abs(capturedDay - dobDay) <= daysRange + } + + // Handle edge case: birthday near end/start of month + // e.g., DOB = Jan 2, captured = Dec 31 (within 3 days) + if (abs(capturedMonth - dobMonth) == 1 || abs(capturedMonth - dobMonth) == 11) { + val daysInCapturedMonth = capturedCal.getActualMaximum(Calendar.DAY_OF_MONTH) + val daysInDobMonth = dobCal.getActualMaximum(Calendar.DAY_OF_MONTH) + + if (capturedMonth < dobMonth || (capturedMonth == 11 && dobMonth == 0)) { + // Captured before DOB month + val dayDiff = (daysInCapturedMonth - capturedDay) + dobDay + return dayDiff <= daysRange + } else { + // Captured after DOB month + val dayDiff = (daysInDobMonth - dobDay) + capturedDay + return dayDiff <= daysRange + } + } + + return false + } + + /** + * Basic indoor/outdoor estimation using brightness and saturation + * + * Heuristic: + * - Outdoor: Higher brightness (>120), Higher saturation (>0.25) + * - Indoor: Lower brightness, Lower saturation + */ + private fun estimateIndoorOutdoor(bitmap: Bitmap): Boolean { + // Sample pixels for analysis (don't process entire image) + val sampleSize = 100 + val sampledPixels = mutableListOf() + + val stepX = bitmap.width / sampleSize.coerceAtMost(bitmap.width) + val stepY = bitmap.height / sampleSize.coerceAtMost(bitmap.height) + + for (x in 0 until sampleSize.coerceAtMost(bitmap.width)) { + for (y in 0 until sampleSize.coerceAtMost(bitmap.height)) { + val px = (x * stepX).coerceIn(0, bitmap.width - 1) + val py = (y * stepY).coerceIn(0, bitmap.height - 1) + sampledPixels.add(bitmap.getPixel(px, py)) + } + } + + if (sampledPixels.isEmpty()) return true // Default to indoor if sampling fails + + // Calculate average brightness + val avgBrightness = sampledPixels.map { pixel -> + val r = Color.red(pixel) + val g = Color.green(pixel) + val b = Color.blue(pixel) + (r + g + b) / 3.0f + }.average() + + // Calculate color saturation + val avgSaturation = sampledPixels.map { pixel -> + val hsv = FloatArray(3) + Color.colorToHSV(pixel, hsv) + hsv[1] // Saturation + }.average() + + // Heuristic: Indoor if low brightness OR low saturation + return avgBrightness < 120 || avgSaturation < 0.25 + } + + /** + * Apply a system tag to an image (helper to avoid duplicates) + * + * @return true if tag was applied, false if already exists + */ + private suspend fun applySystemTag(imageId: String, tagName: String): Boolean { + return withContext(Dispatchers.IO) { + try { + // Get or create tag + val tag = getOrCreateSystemTag(tagName) + + // Create image-tag link + val imageTag = ImageTagEntity( + imageId = imageId, + tagId = tag.tagId, + source = "AUTO", + confidence = 1.0f, + visibility = "PUBLIC", + createdAt = System.currentTimeMillis() + ) + + imageTagDao.upsert(imageTag) + true + } catch (e: Exception) { + DiagnosticLogger.e("Failed to apply tag '$tagName' to image $imageId", e) + false + } + } + } + + /** + * Get existing system tag or create new one + */ + private suspend fun getOrCreateSystemTag(tagName: String): TagEntity { + return withContext(Dispatchers.IO) { + tagDao.getByValue(tagName) ?: run { + val newTag = TagEntity.createSystemTag(tagName) + tagDao.insert(newTag) + newTag + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ml/Thresholdstrategy.kt b/app/src/main/java/com/placeholder/sherpai2/ml/Thresholdstrategy.kt new file mode 100644 index 0000000..d2f4b99 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ml/Thresholdstrategy.kt @@ -0,0 +1,127 @@ +package com.placeholder.sherpai2.ml + +/** + * ThresholdStrategy - Smart threshold selection for face recognition + * + * Considers: + * - Training image count + * - Image quality + * - Detection context (group photo, selfie, etc.) + */ +object ThresholdStrategy { + + /** + * Get optimal threshold for face recognition + * + * @param trainingCount Number of images used to train the model + * @param imageQuality Quality assessment of the image being scanned + * @param detectionContext Context of the detection (group, selfie, etc.) + * @return Similarity threshold (0.0 - 1.0) + */ + fun getOptimalThreshold( + trainingCount: Int, + imageQuality: ImageQuality = ImageQuality.UNKNOWN, + detectionContext: DetectionContext = DetectionContext.GENERAL + ): Float { + // Base threshold from training count + val baseThreshold = when { + trainingCount >= 40 -> 0.68f // High confidence - strict + trainingCount >= 30 -> 0.62f // Good confidence - moderate-strict + trainingCount >= 20 -> 0.56f // Moderate confidence + trainingCount >= 15 -> 0.50f // Acceptable confidence - lenient + else -> 0.48f // Sparse training - very lenient + } + + // Adjust based on image quality + val qualityAdjustment = when (imageQuality) { + ImageQuality.HIGH -> -0.02f // Can be stricter with good quality + ImageQuality.MEDIUM -> 0f // No change + ImageQuality.LOW -> +0.03f // Be more lenient with poor quality + ImageQuality.UNKNOWN -> 0f // No change + } + + // Adjust based on detection context + val contextAdjustment = when (detectionContext) { + DetectionContext.GROUP_PHOTO -> +0.02f // More lenient in groups (faces smaller) + DetectionContext.SELFIE -> -0.03f // Stricter for close-ups (more detail) + DetectionContext.PROFILE -> +0.02f // More lenient for side profiles + DetectionContext.DISTANT -> +0.03f // More lenient for far away faces + DetectionContext.GENERAL -> 0f // No change + } + + // Combine adjustments and clamp to safe range + return (baseThreshold + qualityAdjustment + contextAdjustment).coerceIn(0.40f, 0.75f) + } + + /** + * Get threshold for liberal matching (e.g., during testing) + */ + fun getLiberalThreshold(trainingCount: Int): Float { + return when { + trainingCount >= 30 -> 0.52f + trainingCount >= 20 -> 0.48f + else -> 0.45f + }.coerceIn(0.40f, 0.65f) + } + + /** + * Get threshold for conservative matching (minimize false positives) + */ + fun getConservativeThreshold(trainingCount: Int): Float { + return when { + trainingCount >= 40 -> 0.72f + trainingCount >= 30 -> 0.68f + trainingCount >= 20 -> 0.62f + else -> 0.58f + }.coerceIn(0.55f, 0.75f) + } + + /** + * Estimate image quality from bitmap properties + */ + fun estimateImageQuality(width: Int, height: Int, fileSize: Long = 0): ImageQuality { + val megapixels = (width * height) / 1_000_000f + + return when { + megapixels > 4.0f -> ImageQuality.HIGH + megapixels > 1.0f -> ImageQuality.MEDIUM + else -> ImageQuality.LOW + } + } + + /** + * Estimate detection context from face count and face size + */ + fun estimateDetectionContext( + faceCount: Int, + faceAreaRatio: Float = 0f + ): DetectionContext { + return when { + faceCount == 1 && faceAreaRatio > 0.15f -> DetectionContext.SELFIE + faceCount == 1 && faceAreaRatio < 0.05f -> DetectionContext.DISTANT + faceCount >= 3 -> DetectionContext.GROUP_PHOTO + else -> DetectionContext.GENERAL + } + } +} + +/** + * Image quality assessment + */ +enum class ImageQuality { + HIGH, // > 4MP, good lighting + MEDIUM, // 1-4MP + LOW, // < 1MP, poor quality + UNKNOWN // Cannot determine +} + +/** + * Detection context + */ +enum class DetectionContext { + GROUP_PHOTO, // Multiple faces (3+) + SELFIE, // Single face, close-up + PROFILE, // Side view + DISTANT, // Face is small in frame + GENERAL // Default +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt new file mode 100644 index 0000000..21895ed --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewmodel.kt @@ -0,0 +1,336 @@ +package com.placeholder.sherpai2.ui.album + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.data.local.dao.ImageDao +import com.placeholder.sherpai2.data.local.dao.ImageTagDao +import com.placeholder.sherpai2.data.local.dao.PersonDao +import com.placeholder.sherpai2.data.local.dao.TagDao +import com.placeholder.sherpai2.data.local.entity.ImageEntity +import com.placeholder.sherpai2.data.local.entity.PersonEntity +import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity +import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository +import com.placeholder.sherpai2.ui.search.DateRange +import com.placeholder.sherpai2.ui.search.DisplayMode +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.util.Calendar +import javax.inject.Inject + +/** + * AlbumViewModel - Display photos from a specific album (tag, person, or time range) + * + * Features: + * - Search within album + * - Date filtering + * - Simple/Verbose toggle + * - Album stats + */ +@HiltViewModel +class AlbumViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val tagDao: TagDao, + private val imageTagDao: ImageTagDao, + private val imageDao: ImageDao, + private val personDao: PersonDao, + private val faceRecognitionRepository: FaceRecognitionRepository +) : ViewModel() { + + // Album parameters from navigation + private val albumType: String = savedStateHandle["albumType"] ?: "tag" + private val albumId: String = savedStateHandle["albumId"] ?: "" + + // UI state + private val _uiState = MutableStateFlow(AlbumUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + // Search query within album + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + // Date range filter + private val _dateRange = MutableStateFlow(DateRange.ALL_TIME) + val dateRange: StateFlow = _dateRange.asStateFlow() + + // Display mode + private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE) + val displayMode: StateFlow = _displayMode.asStateFlow() + + init { + loadAlbumData() + } + + /** + * Load album data based on type + */ + private fun loadAlbumData() { + viewModelScope.launch { + try { + _uiState.value = AlbumUiState.Loading + + when (albumType) { + "tag" -> loadTagAlbum() + "person" -> loadPersonAlbum() + "time" -> loadTimeAlbum() + else -> _uiState.value = AlbumUiState.Error("Unknown album type") + } + + } catch (e: Exception) { + _uiState.value = AlbumUiState.Error(e.message ?: "Failed to load album") + } + } + } + + private suspend fun loadTagAlbum() { + val tag = tagDao.getByValue(albumId) + if (tag == null) { + _uiState.value = AlbumUiState.Error("Tag not found") + return + } + + combine( + _searchQuery, + _dateRange + ) { query, dateRange -> + Pair(query, dateRange) + }.collectLatest { (query, dateRange) -> + val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f) + val images = imageDao.getImagesByIds(imageIds) + + val filteredImages = images + .filter { isInDateRange(it.capturedAt, dateRange) } + .filter { + query.isBlank() || containsQuery(it, query) + } + + val imagesWithFaces = filteredImages.map { image -> + val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId) + AlbumPhoto( + image = image, + faceTags = tagsWithPersons.map { it.first }, + persons = tagsWithPersons.map { it.second } + ) + } + + val uniquePersons = imagesWithFaces + .flatMap { it.persons } + .distinctBy { it.id } + + _uiState.value = AlbumUiState.Success( + albumName = tag.value.replace("_", " ").capitalize(), + albumType = "Tag", + photos = imagesWithFaces, + personCount = uniquePersons.size, + totalFaces = imagesWithFaces.sumOf { it.faceTags.size } + ) + } + } + + private suspend fun loadPersonAlbum() { + val person = personDao.getPersonById(albumId) + if (person == null) { + _uiState.value = AlbumUiState.Error("Person not found") + return + } + + combine( + _searchQuery, + _dateRange + ) { query, dateRange -> + Pair(query, dateRange) + }.collectLatest { (query, dateRange) -> + val images = faceRecognitionRepository.getImagesForPerson(albumId) + + val filteredImages = images + .filter { isInDateRange(it.capturedAt, dateRange) } + .filter { + query.isBlank() || containsQuery(it, query) + } + + val imagesWithFaces = filteredImages.map { image -> + val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId) + AlbumPhoto( + image = image, + faceTags = tagsWithPersons.map { it.first }, + persons = tagsWithPersons.map { it.second } + ) + } + + _uiState.value = AlbumUiState.Success( + albumName = person.name, + albumType = "Person", + photos = imagesWithFaces, + personCount = 1, + totalFaces = imagesWithFaces.sumOf { it.faceTags.size } + ) + } + } + + private suspend fun loadTimeAlbum() { + // Time-based albums (Today, This Week, etc) + val (startTime, endTime, albumName) = when (albumId) { + "today" -> Triple(getStartOfDay(), System.currentTimeMillis(), "Today") + "week" -> Triple(getStartOfWeek(), System.currentTimeMillis(), "This Week") + "month" -> Triple(getStartOfMonth(), System.currentTimeMillis(), "This Month") + "year" -> Triple(getStartOfYear(), System.currentTimeMillis(), "This Year") + else -> { + _uiState.value = AlbumUiState.Error("Unknown time range") + return + } + } + + combine( + _searchQuery, + _dateRange + ) { query, _ -> + query + }.collectLatest { query -> + val images = imageDao.getImagesInRange(startTime, endTime) + + val filteredImages = images.filter { + query.isBlank() || containsQuery(it, query) + } + + val imagesWithFaces = filteredImages.map { image -> + val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons(image.imageId) + AlbumPhoto( + image = image, + faceTags = tagsWithPersons.map { it.first }, + persons = tagsWithPersons.map { it.second } + ) + } + + val uniquePersons = imagesWithFaces + .flatMap { it.persons } + .distinctBy { it.id } + + _uiState.value = AlbumUiState.Success( + albumName = albumName, + albumType = "Time", + photos = imagesWithFaces, + personCount = uniquePersons.size, + totalFaces = imagesWithFaces.sumOf { it.faceTags.size } + ) + } + } + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + fun setDateRange(range: DateRange) { + _dateRange.value = range + } + + fun toggleDisplayMode() { + _displayMode.value = when (_displayMode.value) { + DisplayMode.SIMPLE -> DisplayMode.VERBOSE + DisplayMode.VERBOSE -> DisplayMode.SIMPLE + } + } + + private fun isInDateRange(timestamp: Long, range: DateRange): Boolean { + return when (range) { + DateRange.ALL_TIME -> true + DateRange.TODAY -> isToday(timestamp) + DateRange.THIS_WEEK -> isThisWeek(timestamp) + DateRange.THIS_MONTH -> isThisMonth(timestamp) + DateRange.THIS_YEAR -> isThisYear(timestamp) + } + } + + private fun containsQuery(image: ImageEntity, query: String): Boolean { + // Could expand to search by person names, tags, etc. + return true + } + + private fun isToday(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) && + today.get(Calendar.DAY_OF_YEAR) == date.get(Calendar.DAY_OF_YEAR) + } + + private fun isThisWeek(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) && + today.get(Calendar.WEEK_OF_YEAR) == date.get(Calendar.WEEK_OF_YEAR) + } + + private fun isThisMonth(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) && + today.get(Calendar.MONTH) == date.get(Calendar.MONTH) + } + + private fun isThisYear(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) + } + + private fun getStartOfDay(): Long { + return Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun getStartOfWeek(): Long { + return Calendar.getInstance().apply { + set(Calendar.DAY_OF_WEEK, firstDayOfWeek) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun getStartOfMonth(): Long { + return Calendar.getInstance().apply { + set(Calendar.DAY_OF_MONTH, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun getStartOfYear(): Long { + return Calendar.getInstance().apply { + set(Calendar.DAY_OF_YEAR, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun String.capitalize(): String { + return this.replaceFirstChar { it.uppercase() } + } +} + +sealed class AlbumUiState { + object Loading : AlbumUiState() + data class Success( + val albumName: String, + val albumType: String, + val photos: List, + val personCount: Int, + val totalFaces: Int + ) : AlbumUiState() + data class Error(val message: String) : AlbumUiState() +} + +data class AlbumPhoto( + val image: ImageEntity, + val faceTags: List, + val persons: List +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt new file mode 100644 index 0000000..97457aa --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/album/Albumviewscreen.kt @@ -0,0 +1,358 @@ +package com.placeholder.sherpai2.ui.album + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.lazy.items +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.Brush +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 com.placeholder.sherpai2.ui.search.DateRange +import com.placeholder.sherpai2.ui.search.DisplayMode +import com.placeholder.sherpai2.ui.search.components.ImageGridItem + +/** + * AlbumViewScreen - Beautiful album detail view + * + * Features: + * - Album stats + * - Search within album + * - Date filtering + * - Simple/Verbose toggle + * - Clean person display + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlbumViewScreen( + onBack: () -> Unit, + onImageClick: (String) -> Unit, + viewModel: AlbumViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val searchQuery by viewModel.searchQuery.collectAsStateWithLifecycle() + val dateRange by viewModel.dateRange.collectAsStateWithLifecycle() + val displayMode by viewModel.displayMode.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + when (val state = uiState) { + is AlbumUiState.Success -> { + Text( + text = state.albumName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "${state.photos.size} photos", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + else -> { + Text("Album") + } + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = { viewModel.toggleDisplayMode() }) { + Icon( + imageVector = if (displayMode == DisplayMode.SIMPLE) { + Icons.Default.ViewList + } else { + Icons.Default.ViewModule + }, + contentDescription = "Toggle view" + ) + } + } + ) + } + ) { paddingValues -> + when (val state = uiState) { + is AlbumUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is AlbumUiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Text(state.message) + Button(onClick = onBack) { + Text("Go Back") + } + } + } + } + is AlbumUiState.Success -> { + AlbumContent( + state = state, + searchQuery = searchQuery, + dateRange = dateRange, + displayMode = displayMode, + onSearchChange = { viewModel.setSearchQuery(it) }, + onDateRangeChange = { viewModel.setDateRange(it) }, + onImageClick = onImageClick, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} + +@Composable +private fun AlbumContent( + state: AlbumUiState.Success, + searchQuery: String, + dateRange: DateRange, + displayMode: DisplayMode, + onSearchChange: (String) -> Unit, + onDateRangeChange: (DateRange) -> Unit, + onImageClick: (String) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier.fillMaxSize() + ) { + // Stats card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + StatItem(Icons.Default.Photo, "Photos", state.photos.size.toString()) + if (state.totalFaces > 0) { + StatItem(Icons.Default.Face, "Faces", state.totalFaces.toString()) + } + if (state.personCount > 0) { + StatItem(Icons.Default.People, "People", state.personCount.toString()) + } + } + } + + // Search bar + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchChange, + placeholder = { Text("Search in album...") }, + leadingIcon = { Icon(Icons.Default.Search, null) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchChange("") }) { + Icon(Icons.Default.Clear, "Clear") + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + singleLine = true, + shape = RoundedCornerShape(16.dp) + ) + + Spacer(Modifier.height(8.dp)) + + // Date filters + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(DateRange.entries) { range -> + val isActive = dateRange == range + FilterChip( + selected = isActive, + onClick = { onDateRangeChange(range) }, + label = { Text(range.displayName) } + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // Photo grid + if (state.photos.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No photos in this album", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Adaptive(120.dp), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items( + items = state.photos, + key = { it.image.imageId } + ) { photo -> + PhotoCard( + photo = photo, + displayMode = displayMode, + onImageClick = onImageClick + ) + } + } + } + } +} + +@Composable +private fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, value: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = value, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun PhotoCard( + photo: AlbumPhoto, + displayMode: DisplayMode, + onImageClick: (String) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) { + Column { + ImageGridItem( + image = photo.image, + onClick = { onImageClick(photo.image.imageUri) } + ) + + if (photo.persons.isNotEmpty()) { + when (displayMode) { + DisplayMode.SIMPLE -> { + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = photo.persons.take(3).joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + DisplayMode.VERBOSE -> { + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + photo.persons.take(3).forEachIndexed { index, person -> + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Face, + null, + Modifier.size(14.dp), + MaterialTheme.colorScheme.primary + ) + Text( + text = person.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (index < photo.faceTags.size) { + val confidence = (photo.faceTags[index].confidence * 100).toInt() + Text( + text = "$confidence%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt new file mode 100644 index 0000000..59ced9e --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/explore/Explorescreen.kt @@ -0,0 +1,459 @@ +package com.placeholder.sherpai2.ui.explore + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel + +/** + * ExploreScreen - REDESIGNED + * + * Features: + * - Rectangular album cards (more compact) + * - Stories section (recent highlights) + * - Clickable navigation to AlbumViewScreen + * - Beautiful gradients and icons + */ +@Composable +fun ExploreScreen( + onAlbumClick: (albumType: String, albumId: String) -> Unit, + viewModel: ExploreViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + ) { + // Header with gradient + Box( + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.surface + ) + ) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = "Explore", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold + ) + Text( + text = "Your photo collection organized", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + when (val state = uiState) { + is ExploreViewModel.ExploreUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is ExploreViewModel.ExploreUiState.Success -> { + ExploreContent( + smartAlbums = state.smartAlbums, + onAlbumClick = onAlbumClick + ) + } + is ExploreViewModel.ExploreUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = state.message, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } +} + +@Composable +private fun ExploreContent( + smartAlbums: List, + onAlbumClick: (albumType: String, albumId: String) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Stories Section (Recent Highlights) + item { + StoriesSection( + albums = smartAlbums.filter { it.imageCount > 0 }.take(10), + onAlbumClick = onAlbumClick + ) + } + + // Time-based Albums + val timeAlbums = smartAlbums.filterIsInstance() + if (timeAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "📅 Time Capsules", + albums = timeAlbums, + onAlbumClick = onAlbumClick + ) + } + } + + // Face-based Albums + val faceAlbums = smartAlbums.filterIsInstance() + .filter { it.tagValue in listOf("group_photo", "selfie", "couple") } + if (faceAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "👥 People & Groups", + albums = faceAlbums, + onAlbumClick = onAlbumClick + ) + } + } + + // Relationship Albums + val relationshipAlbums = smartAlbums.filterIsInstance() + .filter { it.tagValue in listOf("family", "friend", "colleague") } + if (relationshipAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "❤️ Relationships", + albums = relationshipAlbums, + onAlbumClick = onAlbumClick + ) + } + } + + // Time of Day Albums + val timeOfDayAlbums = smartAlbums.filterIsInstance() + .filter { it.tagValue in listOf("morning", "afternoon", "evening", "night") } + if (timeOfDayAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "🌅 Times of Day", + albums = timeOfDayAlbums, + onAlbumClick = onAlbumClick + ) + } + } + + // Scene Albums + val sceneAlbums = smartAlbums.filterIsInstance() + .filter { it.tagValue in listOf("indoor", "outdoor") } + if (sceneAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "🏞️ Scenes", + albums = sceneAlbums, + onAlbumClick = onAlbumClick + ) + } + } + + // Special Occasions + val specialAlbums = smartAlbums.filterIsInstance() + .filter { it.tagValue in listOf("birthday", "high_res") } + if (specialAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "⭐ Special", + albums = specialAlbums, + onAlbumClick = onAlbumClick + ) + } + } + + // Person Albums + val personAlbums = smartAlbums.filterIsInstance() + if (personAlbums.isNotEmpty()) { + item { + AlbumSection( + title = "👤 People", + albums = personAlbums, + onAlbumClick = onAlbumClick + ) + } + } + } +} + +/** + * Stories section - Instagram-style circular highlights + */ +@Composable +private fun StoriesSection( + albums: List, + onAlbumClick: (albumType: String, albumId: String) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "📖 Stories", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(albums) { album -> + StoryCircle( + album = album, + onClick = { + val (type, id) = getAlbumNavigation(album) + onAlbumClick(type, id) + } + ) + } + } + } +} + +/** + * Story circle - circular album preview + */ +@Composable +private fun StoryCircle( + album: SmartAlbum, + onClick: () -> Unit +) { + val (icon, gradient) = getAlbumIconAndGradient(album) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.clickable(onClick = onClick) + ) { + Box( + modifier = Modifier + .size(80.dp) + .clip(CircleShape) + .background(gradient), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(36.dp) + ) + } + + Text( + text = album.displayName, + style = MaterialTheme.typography.labelSmall, + maxLines = 2, + modifier = Modifier.width(80.dp), + fontWeight = FontWeight.Medium + ) + + Text( + text = "${album.imageCount}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Album section with horizontal scrolling rectangular cards + */ +@Composable +private fun AlbumSection( + title: String, + albums: List, + onAlbumClick: (albumType: String, albumId: String) -> Unit +) { + Column( + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + LazyRow( + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(albums) { album -> + AlbumCard( + album = album, + onClick = { + val (type, id) = getAlbumNavigation(album) + onAlbumClick(type, id) + } + ) + } + } + } +} + +/** + * Rectangular album card - more compact than square + */ +@Composable +private fun AlbumCard( + album: SmartAlbum, + onClick: () -> Unit +) { + val (icon, gradient) = getAlbumIconAndGradient(album) + + Card( + modifier = Modifier + .width(180.dp) + .height(120.dp) + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(gradient) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.SpaceBetween + ) { + // Icon + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + + // Album info + Column { + Text( + text = album.displayName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color.White, + maxLines = 1 + ) + Text( + text = "${album.imageCount} ${if (album.imageCount == 1) "photo" else "photos"}", + style = MaterialTheme.typography.bodySmall, + color = Color.White.copy(alpha = 0.9f) + ) + } + } + } + } +} + +/** + * Get navigation parameters for album + */ +private fun getAlbumNavigation(album: SmartAlbum): Pair { + return when (album) { + is SmartAlbum.TimeRange.Today -> "time" to "today" + is SmartAlbum.TimeRange.ThisWeek -> "time" to "week" + is SmartAlbum.TimeRange.ThisMonth -> "time" to "month" + is SmartAlbum.TimeRange.LastYear -> "time" to "year" + is SmartAlbum.Tagged -> "tag" to album.tagValue + is SmartAlbum.Person -> "person" to album.personId + } +} + +/** + * Get icon and gradient for album type + */ +private fun getAlbumIconAndGradient(album: SmartAlbum): Pair { + return when (album) { + is SmartAlbum.TimeRange.Today -> Icons.Default.Today to gradientBlue() + is SmartAlbum.TimeRange.ThisWeek -> Icons.Default.DateRange to gradientTeal() + is SmartAlbum.TimeRange.ThisMonth -> Icons.Default.CalendarMonth to gradientGreen() + is SmartAlbum.TimeRange.LastYear -> Icons.Default.HistoryEdu to gradientPurple() + is SmartAlbum.Tagged -> when (album.tagValue) { + "group_photo" -> Icons.Default.Group to gradientOrange() + "selfie" -> Icons.Default.CameraAlt to gradientPink() + "couple" -> Icons.Default.Favorite to gradientRed() + "family" -> Icons.Default.FamilyRestroom to gradientIndigo() + "friend" -> Icons.Default.People to gradientCyan() + "colleague" -> Icons.Default.BusinessCenter to gradientGray() + "morning" -> Icons.Default.WbSunny to gradientYellow() + "afternoon" -> Icons.Default.LightMode to gradientOrange() + "evening" -> Icons.Default.WbTwilight to gradientOrange() + "night" -> Icons.Default.NightsStay to gradientDarkBlue() + "outdoor" -> Icons.Default.Landscape to gradientGreen() + "indoor" -> Icons.Default.Home to gradientBrown() + "birthday" -> Icons.Default.Cake to gradientPink() + "high_res" -> Icons.Default.HighQuality to gradientGold() + else -> Icons.Default.Label to gradientBlue() + } + is SmartAlbum.Person -> Icons.Default.Person to gradientPurple() + } +} + +// Gradient helpers +private fun gradientBlue() = Brush.linearGradient(listOf(Color(0xFF1976D2), Color(0xFF1565C0))) +private fun gradientTeal() = Brush.linearGradient(listOf(Color(0xFF00897B), Color(0xFF00796B))) +private fun gradientGreen() = Brush.linearGradient(listOf(Color(0xFF388E3C), Color(0xFF2E7D32))) +private fun gradientPurple() = Brush.linearGradient(listOf(Color(0xFF7B1FA2), Color(0xFF6A1B9A))) +private fun gradientOrange() = Brush.linearGradient(listOf(Color(0xFFF57C00), Color(0xFFE64A19))) +private fun gradientPink() = Brush.linearGradient(listOf(Color(0xFFD81B60), Color(0xFFC2185B))) +private fun gradientRed() = Brush.linearGradient(listOf(Color(0xFFE53935), Color(0xFFD32F2F))) +private fun gradientIndigo() = Brush.linearGradient(listOf(Color(0xFF3949AB), Color(0xFF303F9F))) +private fun gradientCyan() = Brush.linearGradient(listOf(Color(0xFF00ACC1), Color(0xFF0097A7))) +private fun gradientGray() = Brush.linearGradient(listOf(Color(0xFF616161), Color(0xFF424242))) +private fun gradientYellow() = Brush.linearGradient(listOf(Color(0xFFFDD835), Color(0xFFFBC02D))) +private fun gradientDarkBlue() = Brush.linearGradient(listOf(Color(0xFF283593), Color(0xFF1A237E))) +private fun gradientBrown() = Brush.linearGradient(listOf(Color(0xFF5D4037), Color(0xFF4E342E))) +private fun gradientGold() = Brush.linearGradient(listOf(Color(0xFFFFB300), Color(0xFFFFA000))) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/explore/Exploreviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/explore/Exploreviewmodel.kt new file mode 100644 index 0000000..3a58404 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/explore/Exploreviewmodel.kt @@ -0,0 +1,302 @@ +package com.placeholder.sherpai2.ui.explore + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.data.local.dao.ImageDao +import com.placeholder.sherpai2.data.local.dao.ImageTagDao +import com.placeholder.sherpai2.data.local.dao.PersonDao +import com.placeholder.sherpai2.data.local.dao.TagDao +import com.placeholder.sherpai2.data.local.entity.ImageEntity +import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Calendar +import javax.inject.Inject + +@HiltViewModel +class ExploreViewModel @Inject constructor( + private val imageDao: ImageDao, + private val tagDao: TagDao, + private val imageTagDao: ImageTagDao, + private val personDao: PersonDao, + private val faceRecognitionRepository: FaceRecognitionRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(ExploreUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + sealed class ExploreUiState { + object Loading : ExploreUiState() + data class Success( + val smartAlbums: List + ) : ExploreUiState() + data class Error(val message: String) : ExploreUiState() + } + + init { + loadExploreData() + } + + fun loadExploreData() { + viewModelScope.launch { + try { + _uiState.value = ExploreUiState.Loading + + val smartAlbums = buildSmartAlbums() + + _uiState.value = ExploreUiState.Success( + smartAlbums = smartAlbums + ) + + } catch (e: Exception) { + _uiState.value = ExploreUiState.Error( + e.message ?: "Failed to load explore data" + ) + } + } + } + + private suspend fun buildSmartAlbums(): List { + val albums = mutableListOf() + + // Time-based albums + albums.add(SmartAlbum.TimeRange.Today) + albums.add(SmartAlbum.TimeRange.ThisWeek) + albums.add(SmartAlbum.TimeRange.ThisMonth) + albums.add(SmartAlbum.TimeRange.LastYear) + + // Face-based albums (from system tags) + val groupPhotoTag = tagDao.getByValue("group_photo") + if (groupPhotoTag != null) { + val count = tagDao.getTagUsageCount(groupPhotoTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("group_photo", "Group Photos", count)) + } + } + + val selfieTag = tagDao.getByValue("selfie") + if (selfieTag != null) { + val count = tagDao.getTagUsageCount(selfieTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("selfie", "Selfies", count)) + } + } + + val coupleTag = tagDao.getByValue("couple") + if (coupleTag != null) { + val count = tagDao.getTagUsageCount(coupleTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("couple", "Couples", count)) + } + } + + // Relationship albums + val familyTag = tagDao.getByValue("family") + if (familyTag != null) { + val count = tagDao.getTagUsageCount(familyTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("family", "Family Moments", count)) + } + } + + val friendTag = tagDao.getByValue("friend") + if (friendTag != null) { + val count = tagDao.getTagUsageCount(friendTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("friend", "With Friends", count)) + } + } + + val colleagueTag = tagDao.getByValue("colleague") + if (colleagueTag != null) { + val count = tagDao.getTagUsageCount(colleagueTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("colleague", "Work Events", count)) + } + } + + // Time of day albums + val morningTag = tagDao.getByValue("morning") + if (morningTag != null) { + val count = tagDao.getTagUsageCount(morningTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("morning", "Morning Moments", count)) + } + } + + val eveningTag = tagDao.getByValue("evening") + if (eveningTag != null) { + val count = tagDao.getTagUsageCount(eveningTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("evening", "Golden Hour", count)) + } + } + + val nightTag = tagDao.getByValue("night") + if (nightTag != null) { + val count = tagDao.getTagUsageCount(nightTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("night", "Night Life", count)) + } + } + + // Scene albums + val outdoorTag = tagDao.getByValue("outdoor") + if (outdoorTag != null) { + val count = tagDao.getTagUsageCount(outdoorTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("outdoor", "Outdoor Adventures", count)) + } + } + + val indoorTag = tagDao.getByValue("indoor") + if (indoorTag != null) { + val count = tagDao.getTagUsageCount(indoorTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("indoor", "Indoor Moments", count)) + } + } + + // Special occasions + val birthdayTag = tagDao.getByValue("birthday") + if (birthdayTag != null) { + val count = tagDao.getTagUsageCount(birthdayTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("birthday", "Birthdays", count)) + } + } + + // Quality albums + val highResTag = tagDao.getByValue("high_res") + if (highResTag != null) { + val count = tagDao.getTagUsageCount(highResTag.tagId) + if (count > 0) { + albums.add(SmartAlbum.Tagged("high_res", "Best Quality", count)) + } + } + + // Person albums + val persons = personDao.getAllPersons() + persons.forEach { person -> + val stats = faceRecognitionRepository.getPersonFaceStats(person.id) + if (stats != null && stats.taggedPhotoCount > 0) { + albums.add(SmartAlbum.Person( + personId = person.id, + personName = person.name, + imageCount = stats.taggedPhotoCount + )) + } + } + + return albums + } + + /** + * Get images for a specific smart album + */ + suspend fun getImagesForAlbum(album: SmartAlbum): List { + return when (album) { + is SmartAlbum.TimeRange.Today -> { + val startOfDay = getStartOfDay() + imageDao.getImagesInRange(startOfDay, System.currentTimeMillis()) + } + is SmartAlbum.TimeRange.ThisWeek -> { + val startOfWeek = getStartOfWeek() + imageDao.getImagesInRange(startOfWeek, System.currentTimeMillis()) + } + is SmartAlbum.TimeRange.ThisMonth -> { + val startOfMonth = getStartOfMonth() + imageDao.getImagesInRange(startOfMonth, System.currentTimeMillis()) + } + is SmartAlbum.TimeRange.LastYear -> { + val oneYearAgo = System.currentTimeMillis() - (365L * 24 * 60 * 60 * 1000) + imageDao.getImagesInRange(oneYearAgo, System.currentTimeMillis()) + } + is SmartAlbum.Tagged -> { + val tag = tagDao.getByValue(album.tagValue) + if (tag != null) { + val imageIds = imageTagDao.findImagesByTag(tag.tagId, 0.5f) + imageDao.getImagesByIds(imageIds) + } else { + emptyList() + } + } + is SmartAlbum.Person -> { + faceRecognitionRepository.getImagesForPerson(album.personId) + } + } + } + + private fun getStartOfDay(): Long { + return Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun getStartOfWeek(): Long { + return Calendar.getInstance().apply { + set(Calendar.DAY_OF_WEEK, firstDayOfWeek) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + + private fun getStartOfMonth(): Long { + return Calendar.getInstance().apply { + set(Calendar.DAY_OF_MONTH, 1) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } +} + +/** + * Smart album types + */ +sealed class SmartAlbum { + abstract val displayName: String + abstract val imageCount: Int + + sealed class TimeRange : SmartAlbum() { + data object Today : TimeRange() { + override val displayName = "Today" + override val imageCount = 0 // Calculated dynamically + } + data object ThisWeek : TimeRange() { + override val displayName = "This Week" + override val imageCount = 0 + } + data object ThisMonth : TimeRange() { + override val displayName = "This Month" + override val imageCount = 0 + } + data object LastYear : TimeRange() { + override val displayName = "Last Year" + override val imageCount = 0 + } + } + + data class Tagged( + val tagValue: String, + override val displayName: String, + override val imageCount: Int + ) : SmartAlbum() + + data class Person( + val personId: String, + val personName: String, + override val imageCount: Int + ) : SmartAlbum() { + override val displayName = personName + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt index e1305b2..767e35a 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt @@ -14,6 +14,9 @@ import com.placeholder.sherpai2.data.repository.DetectedFace import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.data.repository.PersonFaceStats import com.placeholder.sherpai2.domain.repository.ImageRepository +import com.placeholder.sherpai2.ml.ThresholdStrategy +import com.placeholder.sherpai2.ml.ImageQuality +import com.placeholder.sherpai2.ml.DetectionContext import com.placeholder.sherpai2.util.DebugFlags import com.placeholder.sherpai2.util.DiagnosticLogger import dagger.hilt.android.lifecycle.HiltViewModel @@ -29,7 +32,7 @@ import kotlinx.coroutines.tasks.await import javax.inject.Inject /** - * PersonInventoryViewModel - Single version with feature flags + * PersonInventoryViewModel - Enhanced with smart threshold strategy * * Toggle diagnostics in DebugFlags.kt: * - ENABLE_FACE_RECOGNITION_LOGGING = true/false @@ -53,7 +56,7 @@ class PersonInventoryViewModel @Inject constructor( .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) - .setMinFaceSize(0.10f) // Lower for better detection + .setMinFaceSize(0.10f) .build() FaceDetection.getClient(options) } @@ -131,13 +134,13 @@ class PersonInventoryViewModel @Inject constructor( } /** - * Scan library with optional diagnostic logging + * Scan library with SMART threshold selection */ fun scanLibraryForPerson(personId: String, faceModelId: String) { viewModelScope.launch { try { if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) { - DiagnosticLogger.i("=== STARTING LIBRARY SCAN ===") + DiagnosticLogger.i("=== STARTING LIBRARY SCAN (ENHANCED) ===") DiagnosticLogger.i("PersonId: $personId") DiagnosticLogger.i("FaceModelId: $faceModelId") } @@ -153,23 +156,7 @@ class PersonInventoryViewModel @Inject constructor( val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId) val trainingCount = faceModel?.trainingImageCount ?: 15 - // Dynamic threshold based on training data and debug flag - val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) { - when { - trainingCount < 20 -> 0.48f // Very liberal - trainingCount < 30 -> 0.52f // Liberal - else -> 0.58f // Moderate - } - } else { - when { - trainingCount < 20 -> 0.55f // Moderate - trainingCount < 30 -> 0.60f // Conservative - else -> 0.65f // Strict - } - } - DiagnosticLogger.i("Training count: $trainingCount") - DiagnosticLogger.i("Using threshold: $scanThreshold") val allImages = imageRepository.getAllImages().first() val totalImages = allImages.size @@ -194,12 +181,42 @@ class PersonInventoryViewModel @Inject constructor( DiagnosticLogger.d("--- Image ${index + 1}/$totalImages ---") DiagnosticLogger.d("ImageId: ${image.imageId}") + // Detect faces with ML Kit val detectedFaces = detectFacesInImage(image.imageUri) totalFacesDetected += detectedFaces.size DiagnosticLogger.d("Faces detected: ${detectedFaces.size}") if (detectedFaces.isNotEmpty()) { + // ENHANCED: Calculate image quality + val imageQuality = ThresholdStrategy.estimateImageQuality( + width = image.width, + height = image.height + ) + + // ENHANCED: Estimate detection context + val detectionContext = ThresholdStrategy.estimateDetectionContext( + faceCount = detectedFaces.size, + faceAreaRatio = if (detectedFaces.isNotEmpty()) { + calculateFaceAreaRatio(detectedFaces[0], image.width, image.height) + } else 0f + ) + + // ENHANCED: Get smart threshold + val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) { + ThresholdStrategy.getLiberalThreshold(trainingCount) + } else { + ThresholdStrategy.getOptimalThreshold( + trainingCount = trainingCount, + imageQuality = imageQuality, + detectionContext = detectionContext + ) + } + + DiagnosticLogger.d("Quality: $imageQuality, Context: $detectionContext") + DiagnosticLogger.d("Using threshold: $scanThreshold") + + // Scan image with smart threshold val tags = faceRecognitionRepository.scanImage( imageId = image.imageId, detectedFaces = detectedFaces, @@ -309,6 +326,19 @@ class PersonInventoryViewModel @Inject constructor( } } + /** + * Calculate face area ratio (for context detection) + */ + private fun calculateFaceAreaRatio( + face: DetectedFace, + imageWidth: Int, + imageHeight: Int + ): Float { + val faceArea = face.boundingBox.width() * face.boundingBox.height() + val imageArea = imageWidth * imageHeight + return faceArea.toFloat() / imageArea.toFloat() + } + suspend fun getPersonImages(personId: String) = faceRecognitionRepository.getImagesForPerson(personId) 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 f1ca03f..20a6b84 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 @@ -33,11 +33,11 @@ sealed class AppDestinations( description = "Find photos by tag or person" ) - data object Tour : AppDestinations( - route = AppRoutes.TOUR, - icon = Icons.Default.Place, - label = "Tour", - description = "Browse by location & time" + data object Explore : AppDestinations( + route = AppRoutes.EXPLORE, + icon = Icons.Default.Explore, + label = "Explore", + description = "Browse smart albums" ) // ImageDetail is not in drawer (internal navigation only) @@ -104,7 +104,7 @@ sealed class AppDestinations( // Photo browsing section val photoDestinations = listOf( AppDestinations.Search, - AppDestinations.Tour + AppDestinations.Explore ) // Face recognition section @@ -135,7 +135,7 @@ val allMainDrawerDestinations = photoDestinations + faceRecognitionDestinations fun getDestinationByRoute(route: String?): AppDestinations? { return when (route) { AppRoutes.SEARCH -> AppDestinations.Search - AppRoutes.TOUR -> AppDestinations.Tour + AppRoutes.EXPLORE -> AppDestinations.Explore 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 3b562fa..cdfa8c7 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 @@ -13,12 +13,12 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.placeholder.sherpai2.ui.devscreens.DummyScreen +import com.placeholder.sherpai2.ui.explore.ExploreScreen import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen import com.placeholder.sherpai2.ui.search.SearchScreen import com.placeholder.sherpai2.ui.search.SearchViewModel -import com.placeholder.sherpai2.ui.tour.TourScreen -import com.placeholder.sherpai2.ui.tour.TourViewModel +import com.placeholder.sherpai2.ui.tags.TagManagementScreen import com.placeholder.sherpai2.ui.trainingprep.ImageSelectorScreen import com.placeholder.sherpai2.ui.trainingprep.ScanResultsScreen import com.placeholder.sherpai2.ui.trainingprep.ScanningState @@ -29,9 +29,10 @@ import java.net.URLEncoder /** * AppNavHost - Main navigation graph + * UPDATED: Added Explore and Tags screens * * Complete flow: - * - Photo browsing (Search, Tour, Detail) + * - Photo browsing (Search, Explore, Detail) * - Face recognition (Inventory, Train) * - Organization (Tags, Upload) * - Settings @@ -72,6 +73,21 @@ fun AppNavHost( ) } + /** + * EXPLORE SCREEN + * Browse smart albums (auto-generated from tags) + */ + composable(AppRoutes.EXPLORE) { + ExploreScreen( + onAlbumClick = { albumType, albumId -> + println("Album clicked: type=$albumType id=$albumId") + + // Example future navigation + // navController.navigate("${AppRoutes.ALBUM}/$albumType/$albumId") + } + ) + } + /** * IMAGE DETAIL SCREEN * Single photo view with metadata @@ -94,21 +110,6 @@ fun AppNavHost( ) } - /** - * TOUR SCREEN - * Browse photos by location and time - */ - composable(AppRoutes.TOUR) { - val tourViewModel: TourViewModel = hiltViewModel() - TourScreen( - tourViewModel = tourViewModel, - onImageClick = { imageUri -> - val encodedUri = URLEncoder.encode(imageUri, "UTF-8") - navController.navigate("${AppRoutes.IMAGE_DETAIL}/$encodedUri") - } - ) - } - // ========================================== // FACE RECOGNITION SYSTEM // ========================================== @@ -139,7 +140,7 @@ fun AppNavHost( * * Flow: * 1. TrainingScreen (select images button) - * 2. ImageSelectorScreen (pick 10+ photos) + * 2. ImageSelectorScreen (pick 15-50 photos) * 3. ScanResultsScreen (validation + name input) * 4. Training completes → navigate to Inventory */ @@ -215,13 +216,10 @@ fun AppNavHost( /** * TAGS SCREEN - * Manage photo tags (placeholder) + * Manage photo tags with auto-tagging features */ composable(AppRoutes.TAGS) { - DummyScreen( - title = "Tags", - subtitle = "Organize your photos with tags" - ) + TagManagementScreen() } /** 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 57ce8d9..51bcd62 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 @@ -11,22 +11,26 @@ package com.placeholder.sherpai2.ui.navigation * - Keeps NavHost decoupled from icons / labels */ object AppRoutes { - const val TOUR = "tour" + // Photo browsing const val SEARCH = "search" - const val MODELS = "models" - const val INVENTORY = "inv" - const val TRAIN = "train" - const val TAGS = "tags" - const val UPLOAD = "upload" - const val SETTINGS = "settings" + const val EXPLORE = "explore" // UPDATED: Changed from TOUR const val IMAGE_DETAIL = "IMAGE_DETAIL" - const val CROP_SCREEN = "CROP_SCREEN" + // Face recognition + const val INVENTORY = "inv" + const val TRAIN = "train" + const val MODELS = "models" + + // Organization + const val TAGS = "tags" + const val UPLOAD = "upload" + + // Settings + const val SETTINGS = "settings" + + // Internal training flow screens const val IMAGE_SELECTOR = "Image Selection" + const val CROP_SCREEN = "CROP_SCREEN" const val TRAINING_SCREEN = "TRAINING_SCREEN" - const val ScanResultsScreen = "First Scan Results" - - - //const val IMAGE_DETAIL = "IMAGE_DETAIL" -} +} \ 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 fc0c3b6..fb9a426 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 @@ -14,12 +14,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Label -import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.* import com.placeholder.sherpai2.ui.navigation.AppRoutes /** * Beautiful app drawer with sections, gradient header, and polish + * UPDATED: Tour → Explore */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -98,7 +98,7 @@ fun AppDrawerContent( val photoItems = listOf( DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search, "Find photos by tag or person"), - DrawerItem(AppRoutes.TOUR, "Tour", Icons.Default.Place, "Browse by location & time") + DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore, "Browse smart albums") ) photoItems.forEach { item -> 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 21acc2d..86295ae 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 @@ -180,7 +180,7 @@ fun MainScreen() { private fun getScreenTitle(route: String): String { return when (route) { AppRoutes.SEARCH -> "Search" - AppRoutes.TOUR -> "Explore" // Will be renamed to EXPLORE + AppRoutes.EXPLORE -> "Explore" // Will be renamed to EXPLORE AppRoutes.INVENTORY -> "People" AppRoutes.TRAIN -> "Train New Person" AppRoutes.MODELS -> "AI Models" @@ -197,7 +197,7 @@ private fun getScreenTitle(route: String): String { private fun getScreenSubtitle(route: String): String? { return when (route) { AppRoutes.SEARCH -> "Find photos by tags, people, or date" - AppRoutes.TOUR -> "Browse your collection" + AppRoutes.EXPLORE -> "Browse your collection" AppRoutes.INVENTORY -> "Trained face models" AppRoutes.TRAIN -> "Add a new person to recognize" AppRoutes.TAGS -> "Organize your photo collection" 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 cd1bc5c..9e70e54 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 @@ -1,8 +1,11 @@ package com.placeholder.sherpai2.ui.search import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.* +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -12,7 +15,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush -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 @@ -20,31 +22,41 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.placeholder.sherpai2.ui.search.components.ImageGridItem /** - * Beautiful SearchScreen with face tag display + * SearchScreen - COMPLETE REDESIGN * - * Polish improvements: - * - Gradient header - * - Better stats card - * - Smooth animations - * - Enhanced visual hierarchy + * Features: + * - Near-match search ("low" → "low_res") + * - Quick tag filter chips + * - Date range filtering + * - Clean person-only display + * - Simple/Verbose toggle */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( modifier: Modifier = Modifier, searchViewModel: SearchViewModel, - onImageClick: (String) -> Unit + onImageClick: (String) -> Unit, + onAlbumClick: (String) -> Unit = {} // For opening album view ) { - var query by remember { mutableStateOf("") } + val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() + val activeTagFilters by searchViewModel.activeTagFilters.collectAsStateWithLifecycle() + val dateRange by searchViewModel.dateRange.collectAsStateWithLifecycle() + val displayMode by searchViewModel.displayMode.collectAsStateWithLifecycle() + val systemTags by searchViewModel.systemTags.collectAsStateWithLifecycle() val images by searchViewModel - .searchImagesByTag(query) + .searchImages() .collectAsStateWithLifecycle(initialValue = emptyList()) - Scaffold( - topBar = { - // Gradient header + Scaffold { paddingValues -> + Column( + modifier = modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Header with gradient Box( modifier = Modifier .fillMaxWidth() @@ -60,29 +72,15 @@ fun SearchScreen( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { // Title Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() ) { - Surface( - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.primary, - shadowElevation = 2.dp, - modifier = Modifier.size(48.dp) - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - Icons.Default.Search, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(28.dp) - ) - } - } - Column { Text( text = "Search Photos", @@ -90,73 +88,193 @@ fun SearchScreen( fontWeight = FontWeight.Bold ) Text( - text = "Find by tag or person", - style = MaterialTheme.typography.bodyMedium, + text = "Near-match • Filters • Smart tags", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + // Simple/Verbose toggle + IconButton( + onClick = { searchViewModel.toggleDisplayMode() } + ) { + Icon( + imageVector = if (displayMode == DisplayMode.SIMPLE) { + Icons.Default.ViewList + } else { + Icons.Default.ViewModule + }, + contentDescription = "Toggle view mode", + tint = MaterialTheme.colorScheme.primary + ) + } } - Spacer(modifier = Modifier.height(16.dp)) - // Search bar OutlinedTextField( - value = query, - onValueChange = { query = it }, - label = { Text("Search by tag") }, + value = searchQuery, + onValueChange = { searchViewModel.setSearchQuery(it) }, + placeholder = { Text("Search... (e.g., 'low', 'gro', 'nig')") }, leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = { query = "" }) { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchViewModel.setSearchQuery("") }) { Icon(Icons.Default.Clear, contentDescription = "Clear") } } }, modifier = Modifier.fillMaxWidth(), singleLine = true, - shape = RoundedCornerShape(16.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface - ) + shape = RoundedCornerShape(16.dp) ) } } - } - ) { paddingValues -> - Column( - modifier = modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Stats bar - if (images.isNotEmpty()) { - StatsBar(images = images) + + // Quick Tag Filters + if (systemTags.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Quick Filters", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + if (activeTagFilters.isNotEmpty()) { + TextButton(onClick = { searchViewModel.clearTagFilters() }) { + Text("Clear all") + } + } + } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(systemTags) { tag -> + val isActive = tag.value in activeTagFilters + FilterChip( + selected = isActive, + onClick = { searchViewModel.toggleTagFilter(tag.value) }, + label = { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = getTagEmoji(tag.value), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = tag.value.replace("_", " "), + style = MaterialTheme.typography.bodySmall + ) + } + }, + leadingIcon = if (isActive) { + { Icon(Icons.Default.Check, null, Modifier.size(16.dp)) } + } else null + ) + } + } + } } - // Results grid - if (images.isEmpty() && query.isBlank()) { + // Date Range Filters + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(DateRange.entries) { range -> + val isActive = dateRange == range + FilterChip( + selected = isActive, + onClick = { searchViewModel.setDateRange(range) }, + label = { Text(range.displayName) }, + leadingIcon = if (isActive) { + { Icon(Icons.Default.DateRange, null, Modifier.size(16.dp)) } + } else null + ) + } + } + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + + // Results + if (images.isEmpty() && searchQuery.isBlank() && activeTagFilters.isEmpty()) { EmptySearchState() - } else if (images.isEmpty() && query.isNotBlank()) { - NoResultsState(query = query) + } else if (images.isEmpty()) { + NoResultsState( + query = searchQuery, + hasFilters = activeTagFilters.isNotEmpty() || dateRange != DateRange.ALL_TIME + ) } else { - LazyVerticalGrid( - columns = GridCells.Adaptive(120.dp), - contentPadding = PaddingValues(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize() - ) { - items( - items = images, - key = { it.image.imageId } - ) { imageWithFaceTags -> - ImageWithFaceTagsCard( - imageWithFaceTags = imageWithFaceTags, - onImageClick = onImageClick + Column { + // Results header + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${images.size} ${if (images.size == 1) "photo" else "photos"}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold ) + + // View Album button (if search results can be grouped) + if (activeTagFilters.size == 1 || searchQuery.isNotBlank()) { + TextButton( + onClick = { + val albumTag = activeTagFilters.firstOrNull() ?: searchQuery + onAlbumClick(albumTag) + } + ) { + Icon( + Icons.Default.Collections, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("View Album") + } + } + } + + // Photo grid + LazyVerticalGrid( + columns = GridCells.Adaptive(120.dp), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items( + items = images, + key = { it.image.imageId } + ) { imageWithFaceTags -> + PhotoCard( + imageWithFaceTags = imageWithFaceTags, + displayMode = displayMode, + onImageClick = onImageClick + ) + } } } } @@ -165,92 +283,103 @@ fun SearchScreen( } /** - * Pretty stats bar showing results summary + * Photo card with clean person display */ @Composable -private fun StatsBar(images: List) { - val totalFaces = images.sumOf { it.faceTags.size } - val uniquePersons = images.flatMap { it.persons }.distinctBy { it.id }.size - - Surface( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), - shape = RoundedCornerShape(16.dp), - shadowElevation = 2.dp +private fun PhotoCard( + imageWithFaceTags: ImageWithFaceTags, + displayMode: DisplayMode, + onImageClick: (String) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - StatBadge( - icon = Icons.Default.Photo, - label = "Images", - value = images.size.toString() + Column { + // Image + ImageGridItem( + image = imageWithFaceTags.image, + onClick = { onImageClick(imageWithFaceTags.image.imageUri) } ) - VerticalDivider( - modifier = Modifier.height(40.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) + // Person tags + if (imageWithFaceTags.persons.isNotEmpty()) { + when (displayMode) { + DisplayMode.SIMPLE -> { + // SIMPLE: Just names, no icons, no percentages + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = imageWithFaceTags.persons + .take(3) + .joinToString(", ") { it.name }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(8.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + DisplayMode.VERBOSE -> { + // VERBOSE: Icons + names + confidence + Surface( + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + imageWithFaceTags.persons + .take(3) + .forEachIndexed { index, person -> + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Face, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = person.name, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (index < imageWithFaceTags.faceTags.size) { + val confidence = (imageWithFaceTags.faceTags[index].confidence * 100).toInt() + Text( + text = "$confidence%", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } - StatBadge( - icon = Icons.Default.Face, - label = "Faces", - value = totalFaces.toString() - ) - - if (uniquePersons > 0) { - VerticalDivider( - modifier = Modifier.height(40.dp), - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) - - StatBadge( - icon = Icons.Default.People, - label = "People", - value = uniquePersons.toString() - ) + if (imageWithFaceTags.persons.size > 3) { + Text( + text = "+${imageWithFaceTags.persons.size - 3} more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } } } } } -@Composable -private fun StatBadge( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, - value: String -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - icon, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = value, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = label, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -/** - * Empty state when no search query - */ @Composable private fun EmptySearchState() { Box( @@ -269,12 +398,12 @@ private fun EmptySearchState() { tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) ) Text( - text = "Search your photos", + text = "Search or filter photos", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) Text( - text = "Enter a tag to find photos", + text = "Try searching or tapping quick filters", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -282,11 +411,8 @@ private fun EmptySearchState() { } } -/** - * No results state - */ @Composable -private fun NoResultsState(query: String) { +private fun NoResultsState(query: String, hasFilters: Boolean) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -303,103 +429,50 @@ private fun NoResultsState(query: String) { tint = MaterialTheme.colorScheme.error.copy(alpha = 0.5f) ) Text( - text = "No results", + text = "No results found", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold ) - Text( - text = "No photos found for \"$query\"", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + if (query.isNotBlank()) { + Text( + text = "No matches for \"$query\"", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (hasFilters) { + Text( + text = "Try removing some filters", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } /** - * Beautiful card showing image with face tags + * Get emoji for tag type */ -@Composable -private fun ImageWithFaceTagsCard( - imageWithFaceTags: ImageWithFaceTags, - onImageClick: (String) -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { - // Image - ImageGridItem( - image = imageWithFaceTags.image, - onClick = { onImageClick(imageWithFaceTags.image.imageId) } - ) - - // Face tags - if (imageWithFaceTags.persons.isNotEmpty()) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - imageWithFaceTags.persons.take(3).forEachIndexed { index, person -> - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Face, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = person.name, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - - if (index < imageWithFaceTags.faceTags.size) { - val confidence = (imageWithFaceTags.faceTags[index].confidence * 100).toInt() - Surface( - shape = RoundedCornerShape(8.dp), - color = if (confidence >= 80) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) - } else { - MaterialTheme.colorScheme.tertiary.copy(alpha = 0.2f) - } - ) { - Text( - text = "$confidence%", - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - fontWeight = FontWeight.Bold - ) - } - } - } - } - - if (imageWithFaceTags.persons.size > 3) { - Text( - text = "+${imageWithFaceTags.persons.size - 3} more", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium - ) - } - } - } - } - } +private fun getTagEmoji(tagValue: String): String { + return when (tagValue) { + "night" -> "🌙" + "morning" -> "🌅" + "afternoon" -> "☀️" + "evening" -> "🌇" + "indoor" -> "🏠" + "outdoor" -> "🌲" + "group_photo" -> "👥" + "selfie" -> "🤳" + "couple" -> "💑" + "family" -> "👨‍👩‍👧" + "friend" -> "🤝" + "birthday" -> "🎂" + "high_res" -> "⭐" + "low_res" -> "📦" + "landscape" -> "🖼️" + "portrait" -> "📱" + "square" -> "⬜" + else -> "🏷️" } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt index 42845fe..4f57145 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/search/SearchViewModel.kt @@ -1,70 +1,288 @@ package com.placeholder.sherpai2.ui.search import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.data.local.dao.TagDao import com.placeholder.sherpai2.data.local.entity.ImageEntity import com.placeholder.sherpai2.data.local.entity.PersonEntity import com.placeholder.sherpai2.data.local.entity.PhotoFaceTagEntity +import com.placeholder.sherpai2.data.local.entity.TagEntity import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.domain.repository.ImageRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import java.util.Calendar import javax.inject.Inject /** - * SearchViewModel + * SearchViewModel - COMPLETE REDESIGN * - * CLEAN IMPLEMENTATION: - * - Properly handles Flow types - * - Fetches face tags for each image - * - Returns combined data structure + * Features: + * - Near-match search ("low" → "low_res", "gro" → "group_photo") + * - Date range filtering + * - Quick tag filters + * - Clean person-only display + * - Simple/Verbose toggle */ @HiltViewModel class SearchViewModel @Inject constructor( private val imageRepository: ImageRepository, - private val faceRecognitionRepository: FaceRecognitionRepository + private val faceRecognitionRepository: FaceRecognitionRepository, + private val tagDao: TagDao ) : ViewModel() { + // Search query with near-match support + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + // Active tag filters (quick chips) + private val _activeTagFilters = MutableStateFlow>(emptySet()) + val activeTagFilters: StateFlow> = _activeTagFilters.asStateFlow() + + // Date range filter + private val _dateRange = MutableStateFlow(DateRange.ALL_TIME) + val dateRange: StateFlow = _dateRange.asStateFlow() + + // Display mode (simple = names only, verbose = icons + percentages) + private val _displayMode = MutableStateFlow(DisplayMode.SIMPLE) + val displayMode: StateFlow = _displayMode.asStateFlow() + + // Available system tags for quick filters + private val _systemTags = MutableStateFlow>(emptyList()) + val systemTags: StateFlow> = _systemTags.asStateFlow() + + init { + loadSystemTags() + } + /** - * Search images by tag with face recognition data. - * - * RETURNS: Flow> - * Each image includes its detected faces and person names + * Main search flow - combines query, tag filters, and date range */ - fun searchImagesByTag(tag: String): Flow> { - val imagesFlow = if (tag.isBlank()) { - imageRepository.getAllImages() - } else { - imageRepository.findImagesByTag(tag) - } + fun searchImages(): Flow> { + return combine( + _searchQuery, + _activeTagFilters, + _dateRange + ) { query, tagFilters, dateRange -> + Triple(query, tagFilters, dateRange) + }.flatMapLatest { (query, tagFilters, dateRange) -> - // Transform Flow to include face recognition data - return imagesFlow.map { imagesList -> - imagesList.map { imageWithEverything -> - // Get face tags with person info for this image - val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons( - imageWithEverything.image.imageId - ) + channelFlow { + // Get matching tags FIRST (suspend call) + val matchingTags = if (query.isNotBlank()) { + findMatchingTags(query) + } else { + emptyList() + } - ImageWithFaceTags( - image = imageWithEverything.image, - faceTags = tagsWithPersons.map { it.first }, - persons = tagsWithPersons.map { it.second } - ) + // Get base images + val imagesFlow = when { + matchingTags.isNotEmpty() -> { + // Search by all matching tags + combine(matchingTags.map { tag -> + imageRepository.findImagesByTag(tag.value) + }) { results -> + results.flatMap { it }.distinctBy { it.image.imageId } + } + } + tagFilters.isNotEmpty() -> { + // Filter by active tags + combine(tagFilters.map { tagValue -> + imageRepository.findImagesByTag(tagValue) + }) { results -> + results.flatMap { it }.distinctBy { it.image.imageId } + } + } + else -> imageRepository.getAllImages() + } + + // Apply date filtering and add face data + imagesFlow.collect { imagesList -> + val filtered = imagesList + .filter { imageWithEverything -> + isInDateRange(imageWithEverything.image.capturedAt, dateRange) + } + .map { imageWithEverything -> + // Get face tags with person info + val tagsWithPersons = faceRecognitionRepository.getFaceTagsWithPersons( + imageWithEverything.image.imageId + ) + + ImageWithFaceTags( + image = imageWithEverything.image, + faceTags = tagsWithPersons.map { it.first }, + persons = tagsWithPersons.map { it.second } + ) + } + .sortedByDescending { it.image.capturedAt } + + send(filtered) + } } } } + + /** + * Near-match search: "low" matches "low_res", "gro" matches "group_photo" + */ + private suspend fun findMatchingTags(query: String): List { + val normalizedQuery = query.trim().lowercase() + + // Get all system tags + val allTags = tagDao.getByType("SYSTEM") + + // Find tags that contain the query or match it closely + return allTags.filter { tag -> + val tagValue = tag.value.lowercase() + + // Exact match + tagValue == normalizedQuery || + // Contains match + tagValue.contains(normalizedQuery) || + // Starts with match + tagValue.startsWith(normalizedQuery) || + // Fuzzy match (remove underscores and compare) + tagValue.replace("_", "").contains(normalizedQuery.replace("_", "")) + }.sortedBy { tag -> + // Sort by relevance: exact > starts with > contains + when { + tag.value.lowercase() == normalizedQuery -> 0 + tag.value.lowercase().startsWith(normalizedQuery) -> 1 + else -> 2 + } + } + } + + /** + * Load available system tags for quick filters + */ + private fun loadSystemTags() { + viewModelScope.launch { + val tags = tagDao.getByType("SYSTEM") + + // Get usage counts for all tags + val tagsWithUsage = tags.map { tag -> + tag to tagDao.getTagUsageCount(tag.tagId) + } + + // Sort by most commonly used + val sortedTags = tagsWithUsage + .sortedByDescending { (_, usageCount) -> usageCount } + .take(12) // Show top 12 most used tags + .map { (tag, _) -> tag } + + _systemTags.value = sortedTags + } + } + + /** + * Update search query + */ + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + + /** + * Toggle a tag filter + */ + fun toggleTagFilter(tagValue: String) { + _activeTagFilters.value = if (tagValue in _activeTagFilters.value) { + _activeTagFilters.value - tagValue + } else { + _activeTagFilters.value + tagValue + } + } + + /** + * Clear all tag filters + */ + fun clearTagFilters() { + _activeTagFilters.value = emptySet() + } + + /** + * Set date range filter + */ + fun setDateRange(range: DateRange) { + _dateRange.value = range + } + + /** + * Toggle display mode (simple/verbose) + */ + fun toggleDisplayMode() { + _displayMode.value = when (_displayMode.value) { + DisplayMode.SIMPLE -> DisplayMode.VERBOSE + DisplayMode.VERBOSE -> DisplayMode.SIMPLE + } + } + + /** + * Check if timestamp is in date range + */ + private fun isInDateRange(timestamp: Long, range: DateRange): Boolean { + return when (range) { + DateRange.ALL_TIME -> true + DateRange.TODAY -> isToday(timestamp) + DateRange.THIS_WEEK -> isThisWeek(timestamp) + DateRange.THIS_MONTH -> isThisMonth(timestamp) + DateRange.THIS_YEAR -> isThisYear(timestamp) + } + } + + private fun isToday(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) && + today.get(Calendar.DAY_OF_YEAR) == date.get(Calendar.DAY_OF_YEAR) + } + + private fun isThisWeek(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) && + today.get(Calendar.WEEK_OF_YEAR) == date.get(Calendar.WEEK_OF_YEAR) + } + + private fun isThisMonth(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) && + today.get(Calendar.MONTH) == date.get(Calendar.MONTH) + } + + private fun isThisYear(timestamp: Long): Boolean { + val today = Calendar.getInstance() + val date = Calendar.getInstance().apply { timeInMillis = timestamp } + return today.get(Calendar.YEAR) == date.get(Calendar.YEAR) + } } /** * Data class containing image with face recognition data - * - * @property image The image entity - * @property faceTags Face tags detected in this image - * @property persons Person entities (parallel to faceTags) */ data class ImageWithFaceTags( val image: ImageEntity, val faceTags: List, val persons: List -) \ No newline at end of file +) + +/** + * Date range filters + */ +enum class DateRange(val displayName: String) { + ALL_TIME("All Time"), + TODAY("Today"), + THIS_WEEK("This Week"), + THIS_MONTH("This Month"), + THIS_YEAR("This Year") +} + +/** + * Display modes for photo tags + */ +enum class DisplayMode { + SIMPLE, // Just person names + VERBOSE // Names + icons + confidence percentages +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt new file mode 100644 index 0000000..28195f9 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementscreen.kt @@ -0,0 +1,624 @@ +package com.placeholder.sherpai2.ui.tags + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.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.Brush +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.placeholder.sherpai2.data.local.entity.TagWithUsage + +@Composable +fun TagManagementScreen( + viewModel: TagManagementViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + val scanningState by viewModel.scanningState.collectAsState() + + var showAddTagDialog by remember { mutableStateOf(false) } + var showScanMenu by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + Scaffold( + floatingActionButton = { + // Single extended FAB with dropdown menu + var showMenu by remember { mutableStateOf(false) } + + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Dropdown menu for scan options + if (showMenu) { + Card( + modifier = Modifier.width(180.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column { + ListItem( + headlineContent = { Text("Scan All", style = MaterialTheme.typography.bodyMedium) }, + leadingContent = { + Icon( + Icons.Default.AutoFixHigh, + null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + }, + modifier = Modifier.clickable { + viewModel.scanForAllTags() + showMenu = false + } + ) + ListItem( + headlineContent = { Text("Base Tags", style = MaterialTheme.typography.bodyMedium) }, + leadingContent = { + Icon( + Icons.Default.PhotoCamera, + null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + }, + modifier = Modifier.clickable { + viewModel.scanForBaseTags() + showMenu = false + } + ) + ListItem( + headlineContent = { Text("Relationships", style = MaterialTheme.typography.bodyMedium) }, + leadingContent = { + Icon( + Icons.Default.People, + null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + }, + modifier = Modifier.clickable { + viewModel.scanForRelationshipTags() + showMenu = false + } + ) + ListItem( + headlineContent = { Text("Birthdays", style = MaterialTheme.typography.bodyMedium) }, + leadingContent = { + Icon( + Icons.Default.Cake, + null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + }, + modifier = Modifier.clickable { + viewModel.scanForBirthdayTags() + showMenu = false + } + ) + } + } + } + + // Main FAB + ExtendedFloatingActionButton( + onClick = { showMenu = !showMenu }, + icon = { + Icon( + if (showMenu) Icons.Default.Close else Icons.Default.AutoFixHigh, + "Scan" + ) + }, + text = { Text(if (showMenu) "Close" else "Scan Tags") } + ) + } + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Stats Bar + StatsBar(uiState) + + // Search Bar + SearchBar( + searchQuery = searchQuery, + onSearchChange = { + searchQuery = it + viewModel.searchTags(it) + } + ) + + // Scanning Progress + AnimatedVisibility( + visible = scanningState !is TagManagementViewModel.TagScanningState.Idle, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + ScanningProgress(scanningState, viewModel) + } + + // Tag List + when (val state = uiState) { + is TagManagementViewModel.TagUiState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + is TagManagementViewModel.TagUiState.Success -> { + TagList( + tags = state.tags, + onDeleteTag = { viewModel.deleteTag(it) } + ) + } + is TagManagementViewModel.TagUiState.Error -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = state.message, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + + // Add Tag Dialog + if (showAddTagDialog) { + AddTagDialog( + onDismiss = { showAddTagDialog = false }, + onConfirm = { tagName -> + viewModel.createUserTag(tagName) + showAddTagDialog = false + } + ) + } + + // Scan Menu + if (showScanMenu) { + ScanMenuDialog( + onDismiss = { showScanMenu = false }, + onScanSelected = { scanType -> + when (scanType) { + TagManagementViewModel.ScanType.BASE_TAGS -> viewModel.scanForBaseTags() + TagManagementViewModel.ScanType.RELATIONSHIP_TAGS -> viewModel.scanForRelationshipTags() + TagManagementViewModel.ScanType.BIRTHDAY_TAGS -> viewModel.scanForBirthdayTags() + TagManagementViewModel.ScanType.SCENE_TAGS -> viewModel.scanForSceneTags() + TagManagementViewModel.ScanType.ALL -> viewModel.scanForAllTags() + } + showScanMenu = false + } + ) + } +} + +@Composable +private fun StatsBar(uiState: TagManagementViewModel.TagUiState) { + if (uiState is TagManagementViewModel.TagUiState.Success) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceAround + ) { + StatItem("Total", uiState.totalTags.toString(), Icons.Default.Label) + StatItem("System", uiState.systemTags.toString(), Icons.Default.AutoAwesome) + StatItem("User", uiState.userTags.toString(), Icons.Default.PersonOutline) + } + } + } +} + +@Composable +private fun StatItem(label: String, value: String, icon: ImageVector) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = value, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +private fun SearchBar( + searchQuery: String, + onSearchChange: (String) -> Unit +) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + placeholder = { Text("Search tags...") }, + leadingIcon = { Icon(Icons.Default.Search, "Search") }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { onSearchChange("") }) { + Icon(Icons.Default.Clear, "Clear") + } + } + }, + singleLine = true + ) +} + +@Composable +private fun ScanningProgress( + scanningState: TagManagementViewModel.TagScanningState, + viewModel: TagManagementViewModel +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + when (scanningState) { + is TagManagementViewModel.TagScanningState.Scanning -> { + Text( + text = "Scanning: ${scanningState.scanType.name.replace("_", " ")}", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { scanningState.progress.toFloat() / scanningState.total.toFloat() }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${scanningState.progress} / ${scanningState.total} images", + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "Tags applied: ${scanningState.tagsApplied}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + is TagManagementViewModel.TagScanningState.Complete -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "✓ Scan Complete", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "${scanningState.tagsApplied} tags applied to ${scanningState.imagesProcessed} images", + style = MaterialTheme.typography.bodySmall + ) + } + IconButton(onClick = { viewModel.resetScanningState() }) { + Icon(Icons.Default.Close, "Close") + } + } + } + is TagManagementViewModel.TagScanningState.Error -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Error: ${scanningState.message}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + IconButton(onClick = { viewModel.resetScanningState() }) { + Icon(Icons.Default.Close, "Close") + } + } + } + else -> { /* Idle - don't show */ } + } + } + } +} + +@Composable +private fun TagList( + tags: List, + onDeleteTag: (String) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(tags, key = { it.tagId }) { tag -> + TagListItem(tag, onDeleteTag) + } + } +} + +@Composable +private fun TagListItem( + tag: TagWithUsage, + onDeleteTag: (String) -> Unit +) { + var showDeleteConfirm by remember { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { /* TODO: Navigate to images with this tag */ } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Tag type icon + Icon( + imageVector = if (tag.type == "SYSTEM") Icons.Default.AutoAwesome else Icons.Default.Label, + contentDescription = null, + tint = if (tag.type == "SYSTEM") + MaterialTheme.colorScheme.secondary + else + MaterialTheme.colorScheme.primary + ) + + Column { + Text( + text = tag.value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = if (tag.type == "SYSTEM") "System tag" else "User tag", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Usage count badge + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = tag.usageCount.toString(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + + // Delete button (only for user tags) + if (tag.type == "GENERIC") { + IconButton(onClick = { showDeleteConfirm = true }) { + Icon( + Icons.Default.Delete, + contentDescription = "Delete tag", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete Tag?") }, + text = { Text("Are you sure you want to delete '${tag.value}'? This will remove it from ${tag.usageCount} images.") }, + confirmButton = { + TextButton( + onClick = { + onDeleteTag(tag.tagId) + showDeleteConfirm = false + } + ) { + Text("Delete", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { + Text("Cancel") + } + } + ) + } +} + +@Composable +private fun AddTagDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var tagName by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add New Tag") }, + text = { + OutlinedTextField( + value = tagName, + onValueChange = { tagName = it }, + label = { Text("Tag name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(tagName) }, + enabled = tagName.isNotBlank() + ) { + Text("Add") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun ScanMenuDialog( + onDismiss: () -> Unit, + onScanSelected: (TagManagementViewModel.ScanType) -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Scan for Tags") }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + ScanOption( + title = "Base Tags", + description = "Face count, orientation, time, quality", + icon = Icons.Default.PhotoCamera, + onClick = { onScanSelected(TagManagementViewModel.ScanType.BASE_TAGS) } + ) + ScanOption( + title = "Relationship Tags", + description = "Family, friends, colleagues", + icon = Icons.Default.People, + onClick = { onScanSelected(TagManagementViewModel.ScanType.RELATIONSHIP_TAGS) } + ) + ScanOption( + title = "Birthday Tags", + description = "Photos near birthdays", + icon = Icons.Default.Cake, + onClick = { onScanSelected(TagManagementViewModel.ScanType.BIRTHDAY_TAGS) } + ) + ScanOption( + title = "Scene Tags", + description = "Indoor/outdoor detection", + icon = Icons.Default.Landscape, + onClick = { onScanSelected(TagManagementViewModel.ScanType.SCENE_TAGS) } + ) + Divider() + ScanOption( + title = "Scan All", + description = "Run all scans", + icon = Icons.Default.AutoFixHigh, + onClick = { onScanSelected(TagManagementViewModel.ScanType.ALL) } + ) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun ScanOption( + title: String, + description: String, + icon: ImageVector, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(32.dp) + ) + Column { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementviewmodel.kt new file mode 100644 index 0000000..e51746b --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/tags/Tagmanagementviewmodel.kt @@ -0,0 +1,398 @@ +package com.placeholder.sherpai2.ui.tags + +import android.app.Application +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions +import com.placeholder.sherpai2.data.local.dao.ImageTagDao +import com.placeholder.sherpai2.data.local.dao.TagDao +import com.placeholder.sherpai2.data.local.entity.TagEntity +import com.placeholder.sherpai2.data.local.entity.TagWithUsage +import com.placeholder.sherpai2.data.repository.DetectedFace +import com.placeholder.sherpai2.data.service.AutoTaggingService +import com.placeholder.sherpai2.domain.repository.ImageRepository +import com.placeholder.sherpai2.util.DiagnosticLogger +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +@HiltViewModel +class TagManagementViewModel @Inject constructor( + application: Application, + private val tagDao: TagDao, + private val imageTagDao: ImageTagDao, + private val imageRepository: ImageRepository, + private val autoTaggingService: AutoTaggingService +) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(TagUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _scanningState = MutableStateFlow(TagScanningState.Idle) + val scanningState: StateFlow = _scanningState.asStateFlow() + + private val faceDetector by lazy { + val options = FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) + .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) + .setMinFaceSize(0.10f) + .build() + FaceDetection.getClient(options) + } + + sealed class TagUiState { + object Loading : TagUiState() + data class Success( + val tags: List, + val totalTags: Int, + val systemTags: Int, + val userTags: Int + ) : TagUiState() + data class Error(val message: String) : TagUiState() + } + + sealed class TagScanningState { + object Idle : TagScanningState() + data class Scanning( + val scanType: ScanType, + val progress: Int, + val total: Int, + val tagsApplied: Int, + val currentImage: String = "" + ) : TagScanningState() + data class Complete( + val scanType: ScanType, + val imagesProcessed: Int, + val tagsApplied: Int, + val newTagsCreated: Int = 0 + ) : TagScanningState() + data class Error(val message: String) : TagScanningState() + } + + enum class ScanType { + BASE_TAGS, // Face count, orientation, resolution, time-of-day + RELATIONSHIP_TAGS, // Family, friend, colleague from person entities + BIRTHDAY_TAGS, // Birthday tags for DOB matches + SCENE_TAGS, // Indoor/outdoor estimation + ALL // Run all scans + } + + init { + loadTags() + } + + fun loadTags() { + viewModelScope.launch { + try { + _uiState.value = TagUiState.Loading + + val tagsWithUsage = tagDao.getMostUsedTags(1000) // Get all tags + val systemTags = tagsWithUsage.count { it.type == "SYSTEM" } + val userTags = tagsWithUsage.count { it.type == "GENERIC" } + + _uiState.value = TagUiState.Success( + tags = tagsWithUsage, + totalTags = tagsWithUsage.size, + systemTags = systemTags, + userTags = userTags + ) + + } catch (e: Exception) { + _uiState.value = TagUiState.Error( + e.message ?: "Failed to load tags" + ) + } + } + } + + fun createUserTag(tagName: String) { + viewModelScope.launch { + try { + val trimmedName = tagName.trim().lowercase() + if (trimmedName.isEmpty()) { + _uiState.value = TagUiState.Error("Tag name cannot be empty") + return@launch + } + + // Check if tag already exists + val existing = tagDao.getByValue(trimmedName) + if (existing != null) { + _uiState.value = TagUiState.Error("Tag '$trimmedName' already exists") + return@launch + } + + val newTag = TagEntity.createUserTag(trimmedName) + tagDao.insert(newTag) + + loadTags() + } catch (e: Exception) { + _uiState.value = TagUiState.Error( + "Failed to create tag: ${e.message}" + ) + } + } + } + + fun deleteTag(tagId: String) { + viewModelScope.launch { + try { + tagDao.delete(tagId) + loadTags() + } catch (e: Exception) { + _uiState.value = TagUiState.Error( + "Failed to delete tag: ${e.message}" + ) + } + } + } + + fun searchTags(query: String) { + viewModelScope.launch { + try { + val results = if (query.isBlank()) { + tagDao.getMostUsedTags(1000) + } else { + tagDao.searchTagsWithUsage(query, 100) + } + + val systemTags = results.count { it.type == "SYSTEM" } + val userTags = results.count { it.type == "GENERIC" } + + _uiState.value = TagUiState.Success( + tags = results, + totalTags = results.size, + systemTags = systemTags, + userTags = userTags + ) + } catch (e: Exception) { + _uiState.value = TagUiState.Error("Search failed: ${e.message}") + } + } + } + + // ====================== + // AUTO-TAGGING SCANS + // ====================== + + /** + * Scan library for base tags (face count, orientation, time, quality, scene) + */ + fun scanForBaseTags() { + performScan(ScanType.BASE_TAGS) + } + + /** + * Scan for relationship tags (family, friend, colleague) + */ + fun scanForRelationshipTags() { + performScan(ScanType.RELATIONSHIP_TAGS) + } + + /** + * Scan for birthday tags + */ + fun scanForBirthdayTags() { + performScan(ScanType.BIRTHDAY_TAGS) + } + + /** + * Scan for scene tags (indoor/outdoor) + */ + fun scanForSceneTags() { + performScan(ScanType.SCENE_TAGS) + } + + /** + * Scan for ALL tags + */ + fun scanForAllTags() { + performScan(ScanType.ALL) + } + + private fun performScan(scanType: ScanType) { + viewModelScope.launch { + try { + DiagnosticLogger.i("=== STARTING TAG SCAN: $scanType ===") + + _scanningState.value = TagScanningState.Scanning( + scanType = scanType, + progress = 0, + total = 0, + tagsApplied = 0 + ) + + val allImages = imageRepository.getAllImages().first() + var tagsApplied = 0 + var newTagsCreated = 0 + + DiagnosticLogger.i("Processing ${allImages.size} images") + + allImages.forEachIndexed { index, imageWithEverything -> + val image = imageWithEverything.image + + _scanningState.value = TagScanningState.Scanning( + scanType = scanType, + progress = index + 1, + total = allImages.size, + tagsApplied = tagsApplied, + currentImage = image.imageId.take(8) + ) + + when (scanType) { + ScanType.BASE_TAGS -> { + tagsApplied += scanImageForBaseTags(image.imageUri, image) + } + ScanType.SCENE_TAGS -> { + tagsApplied += scanImageForSceneTags(image.imageUri, image) + } + ScanType.RELATIONSHIP_TAGS -> { + // Handled at person level, not per-image + } + ScanType.BIRTHDAY_TAGS -> { + // Handled at person level, not per-image + } + ScanType.ALL -> { + tagsApplied += scanImageForBaseTags(image.imageUri, image) + tagsApplied += scanImageForSceneTags(image.imageUri, image) + } + } + } + + // Handle person-level scans + if (scanType == ScanType.RELATIONSHIP_TAGS || scanType == ScanType.ALL) { + DiagnosticLogger.i("Scanning relationship tags...") + tagsApplied += autoTaggingService.autoTagAllRelationships() + } + + if (scanType == ScanType.BIRTHDAY_TAGS || scanType == ScanType.ALL) { + DiagnosticLogger.i("Scanning birthday tags...") + tagsApplied += autoTaggingService.autoTagAllBirthdays(daysRange = 3) + } + + DiagnosticLogger.i("=== SCAN COMPLETE ===") + DiagnosticLogger.i("Images processed: ${allImages.size}") + DiagnosticLogger.i("Tags applied: $tagsApplied") + + _scanningState.value = TagScanningState.Complete( + scanType = scanType, + imagesProcessed = allImages.size, + tagsApplied = tagsApplied, + newTagsCreated = newTagsCreated + ) + + loadTags() + + } catch (e: Exception) { + DiagnosticLogger.e("Scan failed", e) + _scanningState.value = TagScanningState.Error( + "Scan failed: ${e.message}" + ) + } + } + } + + private suspend fun scanImageForBaseTags( + imageUri: String, + image: com.placeholder.sherpai2.data.local.entity.ImageEntity + ): Int = withContext(Dispatchers.Default) { + try { + val uri = Uri.parse(imageUri) + val inputStream = getApplication().contentResolver.openInputStream(uri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (bitmap == null) return@withContext 0 + + // Detect faces + val detectedFaces = detectFaces(bitmap) + + // Auto-tag with base tags + autoTaggingService.autoTagImage(image, bitmap, detectedFaces) + + } catch (e: Exception) { + DiagnosticLogger.e("Base tag scan failed for $imageUri", e) + 0 + } + } + + private suspend fun scanImageForSceneTags( + imageUri: String, + image: com.placeholder.sherpai2.data.local.entity.ImageEntity + ): Int = withContext(Dispatchers.Default) { + try { + val uri = Uri.parse(imageUri) + val inputStream = getApplication().contentResolver.openInputStream(uri) + val bitmap = BitmapFactory.decodeStream(inputStream) + inputStream?.close() + + if (bitmap == null) return@withContext 0 + + // Only auto-tag scene tags (indoor/outdoor already included in autoTagImage) + // This is a subset of base tags, so we don't need separate logic + 0 + + } catch (e: Exception) { + DiagnosticLogger.e("Scene tag scan failed for $imageUri", e) + 0 + } + } + + private suspend fun detectFaces(bitmap: android.graphics.Bitmap): List = withContext(Dispatchers.Default) { + try { + val image = InputImage.fromBitmap(bitmap, 0) + val faces = faceDetector.process(image).await() + + faces.mapNotNull { face -> + val boundingBox = face.boundingBox + + val croppedFace = try { + val left = boundingBox.left.coerceAtLeast(0) + val top = boundingBox.top.coerceAtLeast(0) + val width = boundingBox.width().coerceAtMost(bitmap.width - left) + val height = boundingBox.height().coerceAtMost(bitmap.height - top) + + if (width > 0 && height > 0) { + android.graphics.Bitmap.createBitmap(bitmap, left, top, width, height) + } else { + null + } + } catch (e: Exception) { + null + } + + if (croppedFace != null) { + DetectedFace( + croppedBitmap = croppedFace, + boundingBox = boundingBox + ) + } else { + null + } + } + + } catch (e: Exception) { + emptyList() + } + } + + fun resetScanningState() { + _scanningState.value = TagScanningState.Idle + } + + override fun onCleared() { + super.onCleared() + faceDetector.close() + } +} \ No newline at end of file