Util Functions Expansion -

Training UI fix for Physicals

Keep it moving ?
This commit is contained in:
genki
2026-01-11 00:12:55 -05:00
parent 749357ba14
commit ae1b78e170
15 changed files with 2316 additions and 179 deletions

View File

@@ -88,4 +88,7 @@ dependencies {
// Zoomable
implementation(libs.zoomable)
implementation(libs.vico.compose)
implementation(libs.vico.compose.m3)
implementation(libs.vico.core)
}

View File

@@ -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<ImageEntity>
}
// ==========================================
// 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<DateCount>
/**
* 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<MonthCount>
/**
* 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<YearCount>
/**
* 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<List<YearCount>>
/**
* 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<DayOfWeekCount>
/**
* 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<HourCount>
/**
* 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
)

View File

@@ -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<BurstStats?>
/**
* 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
}

View File

@@ -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<TagWithUsage>
// ==========================================
// 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<TagStat>
/**
* 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<List<TagStat>>
/**
* 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<TagStat>
}

View File

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

View File

@@ -196,7 +196,7 @@ fun FacePickerDialog(
) {
Icon(Icons.Default.CheckCircle, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text("Use This Face")
Text("Select")
}
}
}

View File

@@ -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<Uri>) -> Unit
) {
var selectedImages by remember { mutableStateOf<List<Uri>>(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))
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<com.placeholder.sherpai2.data.local.dao.DayOfWeekCount>) {
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() }
}
}

View File

@@ -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>(StatsUiState.Loading)
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
private val _timelineGranularity = MutableStateFlow(TimelineGranularity.MONTHLY)
val timelineGranularity: StateFlow<TimelineGranularity> = _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<YearCount>,
val monthlyCounts: List<MonthCount>,
val dailyCounts: List<DateCount>,
val systemTagStats: List<TagStat>,
val burstStats: BurstStats?,
val dateRange: PhotoDateRange?,
val averagePerDay: Float,
val dayOfWeekCounts: List<DayOfWeekCount>,
val hourCounts: List<HourCount>,
val personCount: Int,
val taggedFaceCount: Int
) : StatsUiState()
data class Error(val message: String) : StatsUiState()
}
/**
* Timeline granularity options
*/
enum class TimelineGranularity {
DAILY,
MONTHLY,
YEARLY
}