diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bad79bc..ff5c02c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -88,4 +88,7 @@ dependencies { // Zoomable implementation(libs.zoomable) + implementation(libs.vico.compose) + implementation(libs.vico.compose.m3) + implementation(libs.vico.core) } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt index e47bfb7..de64238 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageDao.kt @@ -9,6 +9,34 @@ import com.placeholder.sherpai2.data.local.entity.ImageEntity import com.placeholder.sherpai2.data.local.model.ImageWithEverything import kotlinx.coroutines.flow.Flow +/** + * Data classes for statistics queries + */ +data class DateCount( + val date: String, // YYYY-MM-DD format + val count: Int +) + +data class MonthCount( + val month: String, // YYYY-MM format + val count: Int +) + +data class YearCount( + val year: String, // YYYY format + val count: Int +) + +data class DayOfWeekCount( + val dayOfWeek: Int, // 0 = Sunday, 6 = Saturday + val count: Int +) + +data class HourCount( + val hour: Int, // 0-23 + val count: Int +) + @Dao interface ImageDao { @@ -87,4 +115,130 @@ interface ImageDao { */ @Query("SELECT * FROM images ORDER BY capturedAt ASC") suspend fun getAllImagesSortedByTime(): List -} \ No newline at end of file + + // ========================================== + // STATISTICS QUERIES - ADDED FOR STATS SECTION + // ========================================== + + /** + * Get photo counts by date (daily granularity) + * Returns all days that have at least one photo + */ + @Query(""" + SELECT + date(capturedAt/1000, 'unixepoch') as date, + COUNT(*) as count + FROM images + GROUP BY date + ORDER BY date ASC + """) + suspend fun getPhotoCountsByDate(): List + + /** + * Get photo counts by month (monthly granularity) + */ + @Query(""" + SELECT + strftime('%Y-%m', capturedAt/1000, 'unixepoch') as month, + COUNT(*) as count + FROM images + GROUP BY month + ORDER BY month ASC + """) + suspend fun getPhotoCountsByMonth(): List + + /** + * Get photo counts by year (yearly granularity) + */ + @Query(""" + SELECT + strftime('%Y', capturedAt/1000, 'unixepoch') as year, + COUNT(*) as count + FROM images + GROUP BY year + ORDER BY year DESC + """) + suspend fun getPhotoCountsByYear(): List + + /** + * Get photo counts by year (Flow version for reactive UI) + */ + @Query(""" + SELECT + strftime('%Y', capturedAt/1000, 'unixepoch') as year, + COUNT(*) as count + FROM images + GROUP BY year + ORDER BY year DESC + """) + fun getPhotoCountsByYearFlow(): Flow> + + /** + * Get photo counts by day of week (0 = Sunday, 6 = Saturday) + * Shows which days you take the most photos + */ + @Query(""" + SELECT + CAST(strftime('%w', capturedAt/1000, 'unixepoch') AS INTEGER) as dayOfWeek, + COUNT(*) as count + FROM images + GROUP BY dayOfWeek + ORDER BY dayOfWeek ASC + """) + suspend fun getPhotoCountsByDayOfWeek(): List + + /** + * Get photo counts by hour of day (0-23) + * Shows when you take the most photos + */ + @Query(""" + SELECT + CAST(strftime('%H', capturedAt/1000, 'unixepoch') AS INTEGER) as hour, + COUNT(*) as count + FROM images + GROUP BY hour + ORDER BY hour ASC + """) + suspend fun getPhotoCountsByHour(): List + + /** + * Get earliest and latest photo timestamps + * Used for date range calculations + */ + @Query(""" + SELECT + MIN(capturedAt) as earliest, + MAX(capturedAt) as latest + FROM images + """) + suspend fun getPhotoDateRange(): PhotoDateRange? + + /** + * Get photo count for a specific year + */ + @Query(""" + SELECT COUNT(*) FROM images + WHERE strftime('%Y', capturedAt/1000, 'unixepoch') = :year + """) + suspend fun getPhotoCountForYear(year: String): Int + + /** + * Get average photos per day (for stats display) + */ + @Query(""" + SELECT + CAST(COUNT(*) AS REAL) / + CAST((MAX(capturedAt) - MIN(capturedAt)) / 86400000 AS REAL) as avgPerDay + FROM images + WHERE (SELECT COUNT(*) FROM images) > 0 + """) + suspend fun getAveragePhotosPerDay(): Float? +} + +/** + * Data class for date range result + */ +data class PhotoDateRange( + val earliest: Long, + val latest: Long +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageTagDao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageTagDao.kt index d5261ad..c1b8c82 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageTagDao.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/ImageTagDao.kt @@ -9,6 +9,15 @@ import com.placeholder.sherpai2.data.local.entity.ImageTagEntity import com.placeholder.sherpai2.data.local.entity.TagEntity import kotlinx.coroutines.flow.Flow +/** + * Data class for burst statistics + */ +data class BurstStats( + val totalBurstPhotos: Int, + val estimatedBurstGroups: Int, + val burstRepresentatives: Int +) + @Dao interface ImageTagDao { @@ -50,4 +59,84 @@ interface ImageTagDao { */ @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(imageTag: ImageTagEntity): Long + + // ========================================== + // BURST STATISTICS - ADDED FOR STATS SECTION + // ========================================== + + /** + * Get comprehensive burst statistics + * Returns total burst photos, estimated groups, and representative count + */ + @Query(""" + SELECT + (SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst') as totalBurstPhotos, + (SELECT COUNT(DISTINCT it.imageId) / 3 + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst') as estimatedBurstGroups, + (SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst_representative') as burstRepresentatives + """) + suspend fun getBurstStats(): BurstStats? + + /** + * Get burst statistics (Flow version for reactive UI) + */ + @Query(""" + SELECT + (SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst') as totalBurstPhotos, + (SELECT COUNT(DISTINCT it.imageId) / 3 + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst') as estimatedBurstGroups, + (SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst_representative') as burstRepresentatives + """) + fun getBurstStatsFlow(): Flow + + /** + * Get count of burst photos + */ + @Query(""" + SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst' + """) + suspend fun getBurstPhotoCount(): Int + + /** + * Get count of burst representative photos + * (photos marked as the best in each burst sequence) + */ + @Query(""" + SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst_representative' + """) + suspend fun getBurstRepresentativeCount(): Int + + /** + * Get estimated number of burst groups + * Assumes average of 3 photos per burst + */ + @Query(""" + SELECT COUNT(DISTINCT it.imageId) / 3 + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = 'burst' + """) + suspend fun getEstimatedBurstGroupCount(): Int } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/TagDao.kt b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/TagDao.kt index 01fe277..49a4513 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/local/dao/TagDao.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/local/dao/TagDao.kt @@ -9,6 +9,16 @@ import com.placeholder.sherpai2.data.local.entity.TagEntity import com.placeholder.sherpai2.data.local.entity.TagWithUsage import kotlinx.coroutines.flow.Flow +/** + * Data class for tag statistics + */ +data class TagStat( + val tagValue: String, + val tagType: String, + val imageCount: Int, + val tagId: String +) + /** * TagDao - Tag management with face recognition integration * @@ -218,4 +228,70 @@ interface TagDao { LIMIT :limit """) suspend fun searchTagsWithUsage(query: String, limit: Int): List + + // ========================================== + // STATISTICS QUERIES - ADDED FOR STATS SECTION + // ========================================== + + /** + * Get system tag statistics (for utilities stats display) + * Returns tag value, type, and count of tagged images + */ + @Query(""" + SELECT + t.value as tagValue, + t.type as tagType, + COUNT(DISTINCT it.imageId) as imageCount, + t.tagId as tagId + FROM tags t + INNER JOIN image_tags it ON t.tagId = it.tagId + WHERE t.type = 'SYSTEM' + GROUP BY t.tagId + ORDER BY imageCount DESC + """) + suspend fun getSystemTagStats(): List + + /** + * Get system tag statistics (Flow version for reactive UI) + */ + @Query(""" + SELECT + t.value as tagValue, + t.type as tagType, + COUNT(DISTINCT it.imageId) as imageCount, + t.tagId as tagId + FROM tags t + INNER JOIN image_tags it ON t.tagId = it.tagId + WHERE t.type = 'SYSTEM' + GROUP BY t.tagId + ORDER BY imageCount DESC + """) + fun getSystemTagStatsFlow(): Flow> + + /** + * Get count of photos with a specific system tag + */ + @Query(""" + SELECT COUNT(DISTINCT it.imageId) + FROM image_tags it + INNER JOIN tags t ON it.tagId = t.tagId + WHERE t.value = :tagValue AND t.type = 'SYSTEM' + """) + suspend fun getSystemTagCount(tagValue: String): Int + + /** + * Get all tag types with counts + * Shows breakdown of SYSTEM vs USER vs GENERIC tags + */ + @Query(""" + SELECT + t.type as tagValue, + t.type as tagType, + COUNT(DISTINCT t.tagId) as imageCount, + '' as tagId + FROM tags t + GROUP BY t.type + ORDER BY imageCount DESC + """) + suspend fun getTagTypeBreakdown(): List } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Beautifulpersoninfodialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Beautifulpersoninfodialog.kt new file mode 100644 index 0000000..06037b7 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Beautifulpersoninfodialog.kt @@ -0,0 +1,373 @@ +package com.placeholder.sherpai2.ui.trainingprep + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import java.text.SimpleDateFormat +import java.util.* + +/** + * BEAUTIFUL PersonInfoDialog - Modern, centered, spacious + * + * Improvements: + * - Full-screen dialog with proper centering + * - Better spacing and visual hierarchy + * - Larger touch targets + * - Scrollable content + * - Modern rounded design + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BeautifulPersonInfoDialog( + onDismiss: () -> Unit, + onConfirm: (name: String, dateOfBirth: Long?, relationship: String) -> Unit +) { + var name by remember { mutableStateOf("") } + var dateOfBirth by remember { mutableStateOf(null) } + var selectedRelationship by remember { mutableStateOf("Other") } + var showDatePicker by remember { mutableStateOf(false) } + + val relationships = listOf( + "Family" to "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ", + "Friend" to "๐Ÿค", + "Partner" to "โค๏ธ", + "Parent" to "๐Ÿ‘ช", + "Sibling" to "๐Ÿ‘ซ", + "Colleague" to "๐Ÿ’ผ" + ) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.92f) + .fillMaxHeight(0.85f), + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // Header with icon and close button + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(64.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(36.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + + Column { + Text( + "Person Details", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + "Help us organize your photos", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.size(24.dp) + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Scrollable content + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + // Name field + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Name *", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + placeholder = { Text("e.g., John Doe") }, + leadingIcon = { + Icon(Icons.Default.Face, contentDescription = null) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(16.dp), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + capitalization = KeyboardCapitalization.Words + ) + ) + } + + // Birthday (Optional) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Birthday (Optional)", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + OutlinedButton( + onClick = { showDatePicker = true }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (dateOfBirth != null) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else + MaterialTheme.colorScheme.surface + ) + ) { + Icon( + Icons.Default.Cake, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + if (dateOfBirth != null) { + formatDate(dateOfBirth!!) + } else { + "Select Birthday" + }, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(Modifier.weight(1f)) + if (dateOfBirth != null) { + IconButton( + onClick = { dateOfBirth = null }, + modifier = Modifier.size(24.dp) + ) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + // Relationship + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Relationship", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + // 3 columns grid for relationship chips + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + relationships.chunked(3).forEach { rowChips -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + rowChips.forEach { (rel, emoji) -> + FilterChip( + selected = selectedRelationship == rel, + onClick = { selectedRelationship = rel }, + label = { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(emoji, style = MaterialTheme.typography.titleMedium) + Text(rel) + } + }, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp) + ) + } + // Fill empty space if less than 3 chips + repeat(3 - rowChips.size) { + Spacer(Modifier.weight(1f)) + } + } + } + + // "Other" option + FilterChip( + selected = selectedRelationship == "Other", + onClick = { selectedRelationship = "Other" }, + label = { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text("๐Ÿ‘ค", style = MaterialTheme.typography.titleMedium) + Text("Other") + } + }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp) + ) + } + } + + // Privacy note + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(16.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + Column { + Text( + "Privacy First", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + Text( + "All data stays on your device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Action buttons + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text("Cancel", style = MaterialTheme.typography.titleMedium) + } + + Button( + onClick = { + if (name.isNotBlank()) { + onConfirm(name.trim(), dateOfBirth, selectedRelationship) + } + }, + enabled = name.isNotBlank(), + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Icon( + Icons.Default.ArrowForward, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Continue", style = MaterialTheme.typography.titleMedium) + } + } + } + } + } + + // Date picker dialog + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton( + onClick = { + dateOfBirth = System.currentTimeMillis() + showDatePicker = false + } + ) { + Text("OK") + } + }, + dismissButton = { + TextButton(onClick = { showDatePicker = false }) { + Text("Cancel") + } + } + ) { + DatePicker( + state = rememberDatePickerState(), + modifier = Modifier.padding(16.dp) + ) + } + } +} + +private fun formatDate(timestamp: Long): String { + val formatter = SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) + return formatter.format(Date(timestamp)) +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt index 0db70b7..125e171 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerDialog.kt @@ -196,7 +196,7 @@ fun FacePickerDialog( ) { Icon(Icons.Default.CheckCircle, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) - Text("Use This Face") + Text("Select") } } } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ImageSelectorScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ImageSelectorScreen.kt index 5a626e1..a9e04e4 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ImageSelectorScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ImageSelectorScreen.kt @@ -4,32 +4,28 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll 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.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp /** - * Enhanced ImageSelectorScreen + * FIXED ImageSelectorScreen * - * Changes: - * - NO LIMIT on photo count (was 10) - * - Recommends 20-30 photos - * - Real-time progress feedback - * - Quality indicators - * - Training tips + * Fixes: + * - Added verticalScroll to Column for proper scrolling + * - Buttons are now always accessible via scroll + * - Better spacing and padding + * - Cleaner layout structure */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -37,6 +33,7 @@ fun ImageSelectorScreen( onImagesSelected: (List) -> Unit ) { var selectedImages by remember { mutableStateOf>(emptyList()) } + val scrollState = rememberScrollState() val photoPicker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents() @@ -60,6 +57,7 @@ fun ImageSelectorScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) + .verticalScroll(scrollState) // FIXED: Added scrolling .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { @@ -124,8 +122,6 @@ fun ImageSelectorScreen( ProgressCard(selectedImages.size) } - Spacer(Modifier.weight(1f)) - // Select photos button Button( onClick = { photoPicker.launch("image/*") }, @@ -147,7 +143,7 @@ fun ImageSelectorScreen( ) } - // Continue button + // Continue button - FIXED: Always visible via scroll AnimatedVisibility(selectedImages.size >= 15) { Button( onClick = { onImagesSelected(selectedImages) }, @@ -200,6 +196,9 @@ fun ImageSelectorScreen( } } } + + // Bottom spacing to ensure last item is visible + Spacer(Modifier.height(32.dp)) } } } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvedfacepickerdialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvedfacepickerdialog.kt new file mode 100644 index 0000000..ee5d163 --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvedfacepickerdialog.kt @@ -0,0 +1,386 @@ +package com.placeholder.sherpai2.ui.trainingprep + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Rect +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import coil.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * IMPROVED FacePickerDialog + * + * Improvements: + * - Cleaner layout with better spacing + * - Larger face previews + * - Better visual feedback + * - More professional design + */ +@Composable +fun ImprovedFacePickerDialog( + result: FaceDetectionHelper.FaceDetectionResult, + onDismiss: () -> Unit, + onFaceSelected: (Int, Bitmap) -> Unit +) { + val context = LocalContext.current + var selectedFaceIndex by remember { mutableStateOf(0) } // Auto-select first face + var croppedFaces by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + + // Load and crop all faces + LaunchedEffect(result) { + isLoading = true + croppedFaces = withContext(Dispatchers.IO) { + val bitmap = loadBitmapFromUri(context, result.uri) + bitmap?.let { bmp -> + result.faceBounds.map { bounds -> + cropFaceFromBitmap(bmp, bounds) + } + } ?: emptyList() + } + isLoading = false + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.94f) + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Pick a Face", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "${result.faceCount} faces detected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + modifier = Modifier.size(24.dp) + ) + } + } + + // Instruction + Text( + text = "Tap a face below to select it for training:", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + if (isLoading) { + // Loading state + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator() + Text( + "Processing faces...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + // Original image preview (smaller) + Card( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(16.dp) + ) { + AsyncImage( + model = result.uri, + contentDescription = "Original image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + } + + // Face previews section + Text( + text = "Preview (tap to select):", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + // Face preview cards - horizontal scroll if more than 2 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + croppedFaces.forEachIndexed { index, faceBitmap -> + ImprovedFacePreviewCard( + faceBitmap = faceBitmap, + index = index + 1, + isSelected = selectedFaceIndex == index, + onClick = { selectedFaceIndex = index }, + modifier = Modifier.weight(1f) + ) + } + } + + // Helper text + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + "The selected face will be used for training", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(52.dp), + shape = RoundedCornerShape(14.dp) + ) { + Text("Cancel", style = MaterialTheme.typography.titleMedium) + } + + Button( + onClick = { + if (selectedFaceIndex < croppedFaces.size) { + onFaceSelected(selectedFaceIndex, croppedFaces[selectedFaceIndex]) + } + }, + modifier = Modifier + .weight(1f) + .height(52.dp), + enabled = !isLoading && croppedFaces.isNotEmpty(), + shape = RoundedCornerShape(14.dp) + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Use This Face", style = MaterialTheme.typography.titleMedium) + } + } + } + } + } +} + +/** + * Improved face preview card with better visual design + */ +@Composable +private fun ImprovedFacePreviewCard( + faceBitmap: Bitmap, + index: Int, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier + .aspectRatio(0.75f) // Portrait aspect ratio for faces + .clickable(onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) + MaterialTheme.colorScheme.primaryContainer + else + MaterialTheme.colorScheme.surfaceVariant + ), + border = if (isSelected) + BorderStroke(3.dp, MaterialTheme.colorScheme.primary) + else + BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + shape = RoundedCornerShape(16.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = if (isSelected) 8.dp else 2.dp + ) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Face image + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + Image( + bitmap = faceBitmap.asImageBitmap(), + contentDescription = "Face $index", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + + // Selected overlay with checkmark + if (isSelected) { + Surface( + modifier = Modifier + .fillMaxSize(), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + shadowElevation = 4.dp + ) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Selected", + modifier = Modifier + .padding(12.dp) + .size(40.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + } + } + } + + // Face number label + Surface( + modifier = Modifier.fillMaxWidth(), + color = if (isSelected) + MaterialTheme.colorScheme.primary + else + MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) + ) { + Text( + text = "Face $index", + modifier = Modifier.padding(vertical = 12.dp), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + color = if (isSelected) + MaterialTheme.colorScheme.onPrimary + else + MaterialTheme.colorScheme.onSurface + ) + } + } + } +} + +/** + * Helper function to load bitmap from URI + */ +private suspend fun loadBitmapFromUri( + context: android.content.Context, + uri: Uri +): Bitmap? = withContext(Dispatchers.IO) { + try { + val inputStream = context.contentResolver.openInputStream(uri) + BitmapFactory.decodeStream(inputStream)?.also { + inputStream?.close() + } + } catch (e: Exception) { + null + } +} + +/** + * Helper function to crop face from bitmap + */ +private fun cropFaceFromBitmap(bitmap: Bitmap, faceBounds: Rect): Bitmap { + // Add 20% padding around the face + val padding = (faceBounds.width() * 0.2f).toInt() + + val left = (faceBounds.left - padding).coerceAtLeast(0) + val top = (faceBounds.top - padding).coerceAtLeast(0) + val right = (faceBounds.right + padding).coerceAtMost(bitmap.width) + val bottom = (faceBounds.bottom + padding).coerceAtMost(bitmap.height) + + val width = right - left + val height = bottom - top + + return Bitmap.createBitmap(bitmap, left, top, width, height) +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvednameinputdialog.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvednameinputdialog.kt new file mode 100644 index 0000000..6fbabbb --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/Improvednameinputdialog.kt @@ -0,0 +1,231 @@ +package com.placeholder.sherpai2.ui.trainingprep + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +/** + * IMPROVED NameInputDialog - Better centered, cleaner layout + * + * Fixes: + * - Centered dialog with proper constraints + * - Better spacing and padding + * - Clearer visual hierarchy + * - Improved error state handling + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ImprovedNameInputDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, + trainingState: TrainingState +) { + var personName by remember { mutableStateOf("") } + val isError = trainingState is TrainingState.Error + val isProcessing = trainingState is TrainingState.Processing + + Dialog( + onDismissRequest = { + if (!isProcessing) { + onDismiss() + } + } + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.9f) // 90% of screen width + .wrapContentHeight(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Icon + Surface( + shape = RoundedCornerShape(16.dp), + color = if (isError) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.primaryContainer + }, + modifier = Modifier.size(72.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + if (isError) Icons.Default.Warning else Icons.Default.Face, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + } + } + + // Title + Text( + text = if (isError) "Training Error" else "Who is this?", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + // Error message or description + if (isError) { + val error = trainingState as TrainingState.Error + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape(12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + text = error.message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } else { + Text( + text = "Enter the name of the person in these training images. This will help you find their photos later.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + // Name input field + OutlinedTextField( + value = personName, + onValueChange = { personName = it }, + label = { Text("Person's Name") }, + placeholder = { Text("e.g., John Doe") }, + singleLine = true, + enabled = !isProcessing, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (personName.isNotBlank() && !isProcessing) { + onConfirm(personName.trim()) + } + } + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ) + ) + + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Cancel button + if (!isProcessing) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(vertical = 14.dp) + ) { + Text("Cancel") + } + } + + // Confirm button + Button( + onClick = { onConfirm(personName.trim()) }, + enabled = personName.isNotBlank() && !isProcessing, + modifier = Modifier.weight(if (isProcessing) 1f else 1f), + shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(vertical = 14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + } + ) + ) { + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(12.dp)) + Text("Training...") + } else { + Icon( + if (isError) Icons.Default.Refresh else Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(if (isError) "Try Again" else "Start Training") + } + } + } + } + } + } +} + +/** + * Alternative: Use this version in ScanResultsScreen.kt + * + * Replace the existing NameInputDialog function (lines 154-257) with: + * + * @Composable + * private fun NameInputDialog( + * onDismiss: () -> Unit, + * onConfirm: (String) -> Unit, + * trainingState: TrainingState + * ) { + * ImprovedNameInputDialog( + * onDismiss = onDismiss, + * onConfirm = onConfirm, + * trainingState = trainingState + * ) + * } + */ \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt index a3cc33d..cd873d4 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt @@ -32,6 +32,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage +import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog +import com.placeholder.sherpai2.ui.trainingprep.FaceDetectionHelper + @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -125,7 +128,7 @@ fun ScanResultsScreen( // Face Picker Dialog showFacePickerDialog?.let { result -> - FacePickerDialog( + ImprovedFacePickerDialog( // CHANGED result = result, onDismiss = { showFacePickerDialog = null }, onFaceSelected = { faceIndex, croppedFaceBitmap -> diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt index 5f9f1bc..37de939 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainingScreen.kt @@ -16,8 +16,11 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import java.text.SimpleDateFormat import java.util.* +import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog + /** * Beautiful TrainingScreen with person info capture @@ -34,7 +37,9 @@ import java.util.* @Composable fun TrainingScreen( onSelectImages: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + trainViewModel: TrainViewModel = hiltViewModel() + ) { var showInfoDialog by remember { mutableStateOf(false) } @@ -98,12 +103,12 @@ fun TrainingScreen( // Person info dialog if (showInfoDialog) { - PersonInfoDialog( + BeautifulPersonInfoDialog( // CHANGED onDismiss = { showInfoDialog = false }, onConfirm = { name, dob, relationship -> showInfoDialog = false - // TODO: Store this info before photo selection - // For now, just proceed to photo selection + // Store person info in ViewModel + trainViewModel.setPersonInfo(name, dob, relationship) onSelectImages() } ) diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt index 255d854..fe6f689 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/Photoutilitiesscreen.kt @@ -14,15 +14,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.placeholder.sherpai2.ui.utilities.stats.StatsScreen /** - * PhotoUtilitiesScreen - Manage photo collection + * PhotoUtilitiesScreen - UPDATED with Stats tab * * Features: - * - Manual photo scan - * - Duplicate detection - * - Burst detection - * - Quality analysis + * - Stats tab (photo statistics and analytics) + * - Tools tab (scan, duplicates, bursts, quality) */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -32,173 +31,220 @@ fun PhotoUtilitiesScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle() + var selectedTab by remember { mutableStateOf(0) } + Scaffold( topBar = { - TopAppBar( - title = { - Column { - Text( - "Photo Utilities", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - Text( - "Manage your photo collection", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + Column { + TopAppBar( + title = { + Column { + Text( + "Photo Utilities", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + "Manage your photo collection", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ) ) - ) + + TabRow(selectedTabIndex = selectedTab) { + Tab( + selected = selectedTab == 0, + onClick = { selectedTab = 0 }, + text = { Text("Stats") }, + icon = { Icon(Icons.Default.BarChart, "Statistics") } + ) + Tab( + selected = selectedTab == 1, + onClick = { selectedTab = 1 }, + text = { Text("Tools") }, + icon = { Icon(Icons.Default.Build, "Tools") } + ) + } + } } ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Section: Scan & Import - item { - SectionHeader( - title = "Scan & Import", - icon = Icons.Default.Scanner + when (selectedTab) { + 0 -> { + // Stats tab - delegate to StatsScreen + StatsScreen() + } + 1 -> { + // Tools tab - existing utilities + ToolsTabContent( + uiState = uiState, + scanProgress = scanProgress, + onScanPhotos = { viewModel.scanForPhotos() }, + onDetectDuplicates = { viewModel.detectDuplicates() }, + onDetectBursts = { viewModel.detectBursts() }, + onAnalyzeQuality = { viewModel.analyzeQuality() }, + modifier = Modifier.padding(paddingValues) ) } + } + } +} +@Composable +private fun ToolsTabContent( + uiState: UtilitiesUiState, + scanProgress: ScanProgress?, + onScanPhotos: () -> Unit, + onDetectDuplicates: () -> Unit, + onDetectBursts: () -> Unit, + onAnalyzeQuality: () -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Section: Scan & Import + item { + SectionHeader( + title = "Scan & Import", + icon = Icons.Default.Scanner + ) + } + + item { + UtilityCard( + title = "Scan for Photos", + description = "Search your device for new photos", + icon = Icons.Default.PhotoLibrary, + buttonText = "Scan Now", + enabled = uiState !is UtilitiesUiState.Scanning, + onClick = onScanPhotos + ) + } + + // Section: Organization + item { + Spacer(Modifier.height(8.dp)) + SectionHeader( + title = "Organization", + icon = Icons.Default.Folder + ) + } + + item { + UtilityCard( + title = "Detect Duplicates", + description = "Find and tag duplicate photos", + icon = Icons.Default.FileCopy, + buttonText = "Find Duplicates", + enabled = uiState !is UtilitiesUiState.Scanning, + onClick = onDetectDuplicates + ) + } + + item { + UtilityCard( + title = "Detect Bursts", + description = "Group photos taken in rapid succession (3+ in 2 seconds)", + icon = Icons.Default.BurstMode, + buttonText = "Find Bursts", + enabled = uiState !is UtilitiesUiState.Scanning, + onClick = onDetectBursts + ) + } + + // Section: Quality + item { + Spacer(Modifier.height(8.dp)) + SectionHeader( + title = "Quality Analysis", + icon = Icons.Default.HighQuality + ) + } + + item { + UtilityCard( + title = "Find Screenshots & Blurry", + description = "Identify screenshots and low-quality photos", + icon = Icons.Default.PhoneAndroid, + buttonText = "Analyze", + enabled = uiState !is UtilitiesUiState.Scanning, + onClick = onAnalyzeQuality + ) + } + + // Progress indicator + if (scanProgress != null) { item { - UtilityCard( - title = "Scan for Photos", - description = "Search your device for new photos", - icon = Icons.Default.PhotoLibrary, - buttonText = "Scan Now", - enabled = uiState !is UtilitiesUiState.Scanning, - onClick = { viewModel.scanForPhotos() } - ) + ProgressCard(scanProgress) } + } - // Section: Organization - item { - Spacer(Modifier.height(8.dp)) - SectionHeader( - title = "Organization", - icon = Icons.Default.Folder - ) - } - - item { - UtilityCard( - title = "Detect Duplicates", - description = "Find and tag duplicate photos", - icon = Icons.Default.FileCopy, - buttonText = "Find Duplicates", - enabled = uiState !is UtilitiesUiState.Scanning, - onClick = { viewModel.detectDuplicates() } - ) - } - - item { - UtilityCard( - title = "Detect Bursts", - description = "Group photos taken in rapid succession (3+ in 2 seconds)", - icon = Icons.Default.BurstMode, - buttonText = "Find Bursts", - enabled = uiState !is UtilitiesUiState.Scanning, - onClick = { viewModel.detectBursts() } - ) - } - - // Section: Quality - item { - Spacer(Modifier.height(8.dp)) - SectionHeader( - title = "Quality Analysis", - icon = Icons.Default.HighQuality - ) - } - - item { - UtilityCard( - title = "Find Screenshots & Blurry", - description = "Identify screenshots and low-quality photos", - icon = Icons.Default.PhoneAndroid, - buttonText = "Analyze", - enabled = uiState !is UtilitiesUiState.Scanning, - onClick = { viewModel.analyzeQuality() } - ) - } - - // Progress indicator - if (scanProgress != null) { + // Results + when (val state = uiState) { + is UtilitiesUiState.ScanComplete -> { item { - ProgressCard(scanProgress!!) + ResultCard( + title = "Scan Complete", + message = state.message, + icon = Icons.Default.CheckCircle, + iconTint = MaterialTheme.colorScheme.primary + ) } } + is UtilitiesUiState.DuplicatesFound -> { + item { + ResultCard( + title = "Duplicates Found", + message = "Found ${state.groups.size} groups of duplicates (${state.groups.sumOf { it.images.size - 1 }} duplicate photos)", + icon = Icons.Default.Info, + iconTint = MaterialTheme.colorScheme.tertiary + ) + } + } + is UtilitiesUiState.BurstsFound -> { + item { + ResultCard( + title = "Bursts Found", + message = "Found ${state.groups.size} burst sequences (${state.groups.sumOf { it.images.size }} photos total)", + icon = Icons.Default.Info, + iconTint = MaterialTheme.colorScheme.tertiary + ) + } + } + is UtilitiesUiState.QualityAnalysisComplete -> { + item { + ResultCard( + title = "Analysis Complete", + message = "Screenshots: ${state.screenshots}\nBlurry: ${state.blurry}", + icon = Icons.Default.CheckCircle, + iconTint = MaterialTheme.colorScheme.primary + ) + } + } + is UtilitiesUiState.Error -> { + item { + ResultCard( + title = "Error", + message = state.message, + icon = Icons.Default.Error, + iconTint = MaterialTheme.colorScheme.error + ) + } + } + else -> {} + } - // Results - when (val state = uiState) { - is UtilitiesUiState.ScanComplete -> { - item { - ResultCard( - title = "Scan Complete", - message = state.message, - icon = Icons.Default.CheckCircle, - iconTint = MaterialTheme.colorScheme.primary - ) - } - } - is UtilitiesUiState.DuplicatesFound -> { - item { - ResultCard( - title = "Duplicates Found", - message = "Found ${state.groups.size} groups of duplicates (${state.groups.sumOf { it.images.size - 1 }} duplicate photos)", - icon = Icons.Default.Info, - iconTint = MaterialTheme.colorScheme.tertiary - ) - } - } - is UtilitiesUiState.BurstsFound -> { - item { - ResultCard( - title = "Bursts Found", - message = "Found ${state.groups.size} burst sequences (${state.groups.sumOf { it.images.size }} photos total)", - icon = Icons.Default.Info, - iconTint = MaterialTheme.colorScheme.tertiary - ) - } - } - is UtilitiesUiState.QualityAnalysisComplete -> { - item { - ResultCard( - title = "Analysis Complete", - message = "Screenshots: ${state.screenshots}\nBlurry: ${state.blurry}", - icon = Icons.Default.CheckCircle, - iconTint = MaterialTheme.colorScheme.primary - ) - } - } - is UtilitiesUiState.Error -> { - item { - ResultCard( - title = "Error", - message = state.message, - icon = Icons.Default.Error, - iconTint = MaterialTheme.colorScheme.error - ) - } - } - else -> {} - } - - // Info card - item { - Spacer(Modifier.height(8.dp)) - InfoCard() - } + // Info card + item { + Spacer(Modifier.height(8.dp)) + InfoCard() } } } diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/stats/Statsscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/stats/Statsscreen.kt new file mode 100644 index 0000000..899e21b --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/stats/Statsscreen.kt @@ -0,0 +1,635 @@ +package com.placeholder.sherpai2.ui.utilities.stats + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.patrykandpatrick.vico.compose.cartesian.CartesianChartHost +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberBottomAxis +import com.patrykandpatrick.vico.compose.cartesian.axis.rememberStartAxis +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberColumnCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer +import com.patrykandpatrick.vico.compose.cartesian.rememberCartesianChart +import com.patrykandpatrick.vico.compose.common.component.rememberLineComponent +import com.patrykandpatrick.vico.compose.common.component.rememberShapeComponent +import com.patrykandpatrick.vico.compose.common.component.rememberTextComponent +import com.patrykandpatrick.vico.compose.common.of +import com.patrykandpatrick.vico.core.cartesian.data.CartesianChartModelProducer +import com.patrykandpatrick.vico.core.cartesian.data.columnSeries +import com.patrykandpatrick.vico.core.cartesian.data.lineSeries +import com.patrykandpatrick.vico.core.common.shape.Shape +import java.text.SimpleDateFormat +import java.util.* + +/** + * StatsScreen - Beautiful statistics dashboard + * + * Features: + * - Photo count timeline (with granularity toggle) + * - Year-by-year breakdown + * - System tag statistics + * - Burst detection stats + * - Usage patterns (day of week, time of day) + * - Face recognition stats + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StatsScreen( + viewModel: StatsViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val granularity by viewModel.timelineGranularity.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + "Photo Statistics", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + Text( + "Your collection insights", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, "Refresh") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + ) + ) + } + ) { paddingValues -> + when (val state = uiState) { + is StatsUiState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + is StatsUiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + state.message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error + ) + Button(onClick = { viewModel.refresh() }) { + Text("Retry") + } + } + } + } + + is StatsUiState.Success -> { + StatsContent( + state = state, + granularity = granularity, + onGranularityChange = { viewModel.setTimelineGranularity(it) }, + modifier = Modifier.padding(paddingValues) + ) + } + } + } +} + +@Composable +private fun StatsContent( + state: StatsUiState.Success, + granularity: TimelineGranularity, + onGranularityChange: (TimelineGranularity) -> Unit, + modifier: Modifier = Modifier +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Overview stats cards + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + title = "Total Photos", + value = state.totalPhotos.toString(), + icon = Icons.Default.Photo, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + StatCard( + title = "Per Day", + value = String.format("%.1f", state.averagePerDay), + icon = Icons.Default.CalendarToday, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.weight(1f) + ) + } + } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StatCard( + title = "People", + value = state.personCount.toString(), + icon = Icons.Default.Face, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.weight(1f) + ) + state.burstStats?.let { burst -> + StatCard( + title = "Burst Groups", + value = burst.estimatedBurstGroups.toString(), + icon = Icons.Default.BurstMode, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f) + ) + } + } + } + + // Timeline chart + item { + SectionHeader("Photo Timeline") + } + + item { + TimelineChart( + state = state, + granularity = granularity, + onGranularityChange = onGranularityChange + ) + } + + // Year breakdown + item { + Spacer(Modifier.height(8.dp)) + SectionHeader("Photos by Year") + } + + items(state.yearCounts) { yearCount -> + YearStatRow( + year = yearCount.year, + count = yearCount.count, + totalPhotos = state.totalPhotos + ) + } + + // System tags + if (state.systemTagStats.isNotEmpty()) { + item { + Spacer(Modifier.height(8.dp)) + SectionHeader("System Tags") + } + + items(state.systemTagStats) { tagStat -> + TagStatRow(tagStat) + } + } + + // Usage patterns + if (state.dayOfWeekCounts.isNotEmpty()) { + item { + Spacer(Modifier.height(8.dp)) + SectionHeader("When You Shoot") + } + + item { + DayOfWeekChart(state.dayOfWeekCounts) + } + } + + // Date range info + state.dateRange?.let { range -> + item { + Spacer(Modifier.height(8.dp)) + DateRangeCard(range) + } + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(vertical = 8.dp) + ) +} + +@Composable +private fun StatCard( + title: String, + value: String, + icon: ImageVector, + color: Color, + modifier: Modifier = Modifier +) { + Card( + modifier = modifier, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = 0.1f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(32.dp), + tint = color + ) + Text( + value, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = color + ) + Text( + title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun TimelineChart( + state: StatsUiState.Success, + granularity: TimelineGranularity, + onGranularityChange: (TimelineGranularity) -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Granularity selector + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TimelineGranularity.entries.forEach { g -> + FilterChip( + selected = granularity == g, + onClick = { onGranularityChange(g) }, + label = { + Text( + when (g) { + TimelineGranularity.DAILY -> "Daily" + TimelineGranularity.MONTHLY -> "Monthly" + TimelineGranularity.YEARLY -> "Yearly" + } + ) + } + ) + } + } + + // Chart + val modelProducer = remember { CartesianChartModelProducer.build() } + + LaunchedEffect(granularity, state) { + val data = when (granularity) { + TimelineGranularity.DAILY -> state.dailyCounts.map { it.count.toFloat() } + TimelineGranularity.MONTHLY -> state.monthlyCounts.map { it.count.toFloat() } + TimelineGranularity.YEARLY -> state.yearCounts.reversed().map { it.count.toFloat() } + } + + if (data.isNotEmpty()) { + modelProducer.tryRunTransaction { + lineSeries { series(data) } + } + } + } + + if (state.dailyCounts.isNotEmpty()) { + CartesianChartHost( + chart = rememberCartesianChart( + rememberLineCartesianLayer(), + startAxis = rememberStartAxis(), + bottomAxis = rememberBottomAxis() + ), + modelProducer = modelProducer, + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) + } else { + Text( + "No data available", + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun YearStatRow( + year: String, + count: Int, + totalPhotos: Int +) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + year, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + val percentage = if (totalPhotos > 0) { + (count.toFloat() / totalPhotos * 100).toInt() + } else 0 + Text( + "$percentage% of collection", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + count.toString(), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} + +@Composable +private fun TagStatRow(tagStat: com.placeholder.sherpai2.data.local.dao.TagStat) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Icon( + getTagIcon(tagStat.tagValue), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + Text( + tagStat.tagValue.replace("_", " ").capitalize(), + style = MaterialTheme.typography.bodyLarge + ) + } + + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Text( + tagStat.imageCount.toString(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } +} + +@Composable +private fun DayOfWeekChart(counts: List) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + val days = listOf("Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat") + val maxCount = counts.maxOfOrNull { it.count } ?: 1 + + counts.forEach { dayCount -> + val dayName = days.getOrNull(dayCount.dayOfWeek) ?: "?" + val percentage = (dayCount.count.toFloat() / maxCount) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + dayName, + modifier = Modifier.width(50.dp), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + + Box( + modifier = Modifier + .weight(1f) + .height(32.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp) + ) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(percentage) + .background( + MaterialTheme.colorScheme.primary, + RoundedCornerShape(4.dp) + ) + ) + Text( + dayCount.count.toString(), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 8.dp), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } +} + +@Composable +private fun DateRangeCard(range: com.placeholder.sherpai2.data.local.dao.PhotoDateRange) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.DateRange, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Collection Date Range", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold + ) + } + + val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + val earliest = dateFormat.format(Date(range.earliest)) + val latest = dateFormat.format(Date(range.latest)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text( + "Earliest", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + earliest, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + "Latest", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + latest, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + } + } + } + } +} + +private fun getTagIcon(tagValue: String): ImageVector { + return when (tagValue) { + "burst" -> Icons.Default.BurstMode + "duplicate" -> Icons.Default.FileCopy + "screenshot" -> Icons.Default.Screenshot + "blurry" -> Icons.Default.BlurOn + "low_quality" -> Icons.Default.LowPriority + else -> Icons.Default.LocalOffer + } +} + +private fun String.capitalize(): String { + return this.split("_").joinToString(" ") { word -> + word.replaceFirstChar { it.uppercase() } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/utilities/stats/Statsviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/stats/Statsviewmodel.kt new file mode 100644 index 0000000..c41b00e --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/utilities/stats/Statsviewmodel.kt @@ -0,0 +1,127 @@ +package com.placeholder.sherpai2.ui.utilities.stats + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.placeholder.sherpai2.data.local.dao.* +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.launch +import javax.inject.Inject + +/** + * StatsViewModel - Photo collection statistics + * + * Features: + * 1. Photo count timeline (daily/monthly/yearly) + * 2. Year-by-year breakdown + * 3. System tag statistics + * 4. Burst detection stats + * 5. Usage patterns (day of week, hour of day) + */ +@HiltViewModel +class StatsViewModel @Inject constructor( + private val imageDao: ImageDao, + private val tagDao: TagDao, + private val imageTagDao: ImageTagDao, + private val personDao: PersonDao, + private val photoFaceTagDao: PhotoFaceTagDao +) : ViewModel() { + + private val _uiState = MutableStateFlow(StatsUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _timelineGranularity = MutableStateFlow(TimelineGranularity.MONTHLY) + val timelineGranularity: StateFlow = _timelineGranularity.asStateFlow() + + init { + loadStats() + } + + fun loadStats() { + viewModelScope.launch(Dispatchers.IO) { + try { + _uiState.value = StatsUiState.Loading + + // Load all stats in parallel + val totalCount = imageDao.getImageCount() + val yearCounts = imageDao.getPhotoCountsByYear() + val monthlyCounts = imageDao.getPhotoCountsByMonth() + val dailyCounts = imageDao.getPhotoCountsByDate() + val systemTagStats = tagDao.getSystemTagStats() + val burstStats = imageTagDao.getBurstStats() + val dateRange = imageDao.getPhotoDateRange() + val avgPerDay = imageDao.getAveragePhotosPerDay() + val dayOfWeekCounts = imageDao.getPhotoCountsByDayOfWeek() + val hourCounts = imageDao.getPhotoCountsByHour() + + // Face recognition stats + val personCount = personDao.getPersonCount() + val taggedFaceCount = photoFaceTagDao.getUnverifiedTagCount() + + _uiState.value = StatsUiState.Success( + totalPhotos = totalCount, + yearCounts = yearCounts, + monthlyCounts = monthlyCounts, + dailyCounts = dailyCounts, + systemTagStats = systemTagStats, + burstStats = burstStats, + dateRange = dateRange, + averagePerDay = avgPerDay ?: 0f, + dayOfWeekCounts = dayOfWeekCounts, + hourCounts = hourCounts, + personCount = personCount, + taggedFaceCount = taggedFaceCount + ) + + } catch (e: Exception) { + _uiState.value = StatsUiState.Error( + e.message ?: "Failed to load statistics" + ) + } + } + } + + fun setTimelineGranularity(granularity: TimelineGranularity) { + _timelineGranularity.value = granularity + } + + fun refresh() { + loadStats() + } +} + +/** + * UI State for stats screen + */ +sealed class StatsUiState { + object Loading : StatsUiState() + + data class Success( + val totalPhotos: Int, + val yearCounts: List, + val monthlyCounts: List, + val dailyCounts: List, + val systemTagStats: List, + val burstStats: BurstStats?, + val dateRange: PhotoDateRange?, + val averagePerDay: Float, + val dayOfWeekCounts: List, + val hourCounts: List, + val personCount: Int, + val taggedFaceCount: Int + ) : StatsUiState() + + data class Error(val message: String) : StatsUiState() +} + +/** + * Timeline granularity options + */ +enum class TimelineGranularity { + DAILY, + MONTHLY, + YEARLY +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 49ae529..b2b4df5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,10 @@ gson = "2.10.1" #Album/Image View Tools zoomable = "1.6.1" +#Charting Lib +vico = "2.0.0-alpha.28" + + [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -75,6 +79,12 @@ gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } #Album/Image View Tools zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" } +vico-compose = { module = "com.patrykandpatrick.vico:compose", version.ref = "vico" } +vico-compose-m3 = { module = "com.patrykandpatrick.vico:compose-m3", version.ref = "vico" } +vico-core = { module = "com.patrykandpatrick.vico:core", version.ref = "vico" } + + + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }