Util Functions Expansion -
Training UI fix for Physicals Keep it moving ?
This commit is contained in:
@@ -88,4 +88,7 @@ dependencies {
|
|||||||
|
|
||||||
// Zoomable
|
// Zoomable
|
||||||
implementation(libs.zoomable)
|
implementation(libs.zoomable)
|
||||||
|
implementation(libs.vico.compose)
|
||||||
|
implementation(libs.vico.compose.m3)
|
||||||
|
implementation(libs.vico.core)
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,34 @@ import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
|||||||
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
import com.placeholder.sherpai2.data.local.model.ImageWithEverything
|
||||||
import kotlinx.coroutines.flow.Flow
|
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
|
@Dao
|
||||||
interface ImageDao {
|
interface ImageDao {
|
||||||
|
|
||||||
@@ -87,4 +115,130 @@ interface ImageDao {
|
|||||||
*/
|
*/
|
||||||
@Query("SELECT * FROM images ORDER BY capturedAt ASC")
|
@Query("SELECT * FROM images ORDER BY capturedAt ASC")
|
||||||
suspend fun getAllImagesSortedByTime(): List<ImageEntity>
|
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
|
||||||
|
)
|
||||||
@@ -9,6 +9,15 @@ import com.placeholder.sherpai2.data.local.entity.ImageTagEntity
|
|||||||
import com.placeholder.sherpai2.data.local.entity.TagEntity
|
import com.placeholder.sherpai2.data.local.entity.TagEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class for burst statistics
|
||||||
|
*/
|
||||||
|
data class BurstStats(
|
||||||
|
val totalBurstPhotos: Int,
|
||||||
|
val estimatedBurstGroups: Int,
|
||||||
|
val burstRepresentatives: Int
|
||||||
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface ImageTagDao {
|
interface ImageTagDao {
|
||||||
|
|
||||||
@@ -50,4 +59,84 @@ interface ImageTagDao {
|
|||||||
*/
|
*/
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
suspend fun insert(imageTag: ImageTagEntity): Long
|
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
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,16 @@ import com.placeholder.sherpai2.data.local.entity.TagEntity
|
|||||||
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
|
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
|
||||||
import kotlinx.coroutines.flow.Flow
|
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
|
* TagDao - Tag management with face recognition integration
|
||||||
*
|
*
|
||||||
@@ -218,4 +228,70 @@ interface TagDao {
|
|||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
suspend fun searchTagsWithUsage(query: String, limit: Int): List<TagWithUsage>
|
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>
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -196,7 +196,7 @@ fun FacePickerDialog(
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
Icon(Icons.Default.CheckCircle, contentDescription = null)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Use This Face")
|
Text("Select")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,28 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced ImageSelectorScreen
|
* FIXED ImageSelectorScreen
|
||||||
*
|
*
|
||||||
* Changes:
|
* Fixes:
|
||||||
* - NO LIMIT on photo count (was 10)
|
* - Added verticalScroll to Column for proper scrolling
|
||||||
* - Recommends 20-30 photos
|
* - Buttons are now always accessible via scroll
|
||||||
* - Real-time progress feedback
|
* - Better spacing and padding
|
||||||
* - Quality indicators
|
* - Cleaner layout structure
|
||||||
* - Training tips
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,6 +33,7 @@ fun ImageSelectorScreen(
|
|||||||
onImagesSelected: (List<Uri>) -> Unit
|
onImagesSelected: (List<Uri>) -> Unit
|
||||||
) {
|
) {
|
||||||
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
val photoPicker = rememberLauncherForActivityResult(
|
val photoPicker = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetMultipleContents()
|
contract = ActivityResultContracts.GetMultipleContents()
|
||||||
@@ -60,6 +57,7 @@ fun ImageSelectorScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(scrollState) // FIXED: Added scrolling
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
@@ -124,8 +122,6 @@ fun ImageSelectorScreen(
|
|||||||
ProgressCard(selectedImages.size)
|
ProgressCard(selectedImages.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
// Select photos button
|
// Select photos button
|
||||||
Button(
|
Button(
|
||||||
onClick = { photoPicker.launch("image/*") },
|
onClick = { photoPicker.launch("image/*") },
|
||||||
@@ -147,7 +143,7 @@ fun ImageSelectorScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Continue button
|
// Continue button - FIXED: Always visible via scroll
|
||||||
AnimatedVisibility(selectedImages.size >= 15) {
|
AnimatedVisibility(selectedImages.size >= 15) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onImagesSelected(selectedImages) },
|
onClick = { onImagesSelected(selectedImages) },
|
||||||
@@ -200,6 +196,9 @@ fun ImageSelectorScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bottom spacing to ensure last item is visible
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
*/
|
||||||
@@ -32,6 +32,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog
|
||||||
|
import com.placeholder.sherpai2.ui.trainingprep.FaceDetectionHelper
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -125,7 +128,7 @@ fun ScanResultsScreen(
|
|||||||
|
|
||||||
// Face Picker Dialog
|
// Face Picker Dialog
|
||||||
showFacePickerDialog?.let { result ->
|
showFacePickerDialog?.let { result ->
|
||||||
FacePickerDialog(
|
ImprovedFacePickerDialog( // CHANGED
|
||||||
result = result,
|
result = result,
|
||||||
onDismiss = { showFacePickerDialog = null },
|
onDismiss = { showFacePickerDialog = null },
|
||||||
onFaceSelected = { faceIndex, croppedFaceBitmap ->
|
onFaceSelected = { faceIndex, croppedFaceBitmap ->
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import androidx.compose.ui.graphics.Brush
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import com.placeholder.sherpai2.ui.trainingprep.BeautifulPersonInfoDialog
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Beautiful TrainingScreen with person info capture
|
* Beautiful TrainingScreen with person info capture
|
||||||
@@ -34,7 +37,9 @@ import java.util.*
|
|||||||
@Composable
|
@Composable
|
||||||
fun TrainingScreen(
|
fun TrainingScreen(
|
||||||
onSelectImages: () -> Unit,
|
onSelectImages: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
trainViewModel: TrainViewModel = hiltViewModel()
|
||||||
|
|
||||||
) {
|
) {
|
||||||
var showInfoDialog by remember { mutableStateOf(false) }
|
var showInfoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -98,12 +103,12 @@ fun TrainingScreen(
|
|||||||
|
|
||||||
// Person info dialog
|
// Person info dialog
|
||||||
if (showInfoDialog) {
|
if (showInfoDialog) {
|
||||||
PersonInfoDialog(
|
BeautifulPersonInfoDialog( // CHANGED
|
||||||
onDismiss = { showInfoDialog = false },
|
onDismiss = { showInfoDialog = false },
|
||||||
onConfirm = { name, dob, relationship ->
|
onConfirm = { name, dob, relationship ->
|
||||||
showInfoDialog = false
|
showInfoDialog = false
|
||||||
// TODO: Store this info before photo selection
|
// Store person info in ViewModel
|
||||||
// For now, just proceed to photo selection
|
trainViewModel.setPersonInfo(name, dob, relationship)
|
||||||
onSelectImages()
|
onSelectImages()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.placeholder.sherpai2.ui.utilities.stats.StatsScreen
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PhotoUtilitiesScreen - Manage photo collection
|
* PhotoUtilitiesScreen - UPDATED with Stats tab
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Manual photo scan
|
* - Stats tab (photo statistics and analytics)
|
||||||
* - Duplicate detection
|
* - Tools tab (scan, duplicates, bursts, quality)
|
||||||
* - Burst detection
|
|
||||||
* - Quality analysis
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -32,173 +31,220 @@ fun PhotoUtilitiesScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle()
|
val scanProgress by viewModel.scanProgress.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
var selectedTab by remember { mutableStateOf(0) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
Column {
|
||||||
title = {
|
TopAppBar(
|
||||||
Column {
|
title = {
|
||||||
Text(
|
Column {
|
||||||
"Photo Utilities",
|
Text(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
"Photo Utilities",
|
||||||
fontWeight = FontWeight.Bold
|
style = MaterialTheme.typography.titleLarge,
|
||||||
)
|
fontWeight = FontWeight.Bold
|
||||||
Text(
|
)
|
||||||
"Manage your photo collection",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
"Manage your photo collection",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
style = MaterialTheme.typography.bodySmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
}
|
)
|
||||||
},
|
}
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
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 ->
|
) { paddingValues ->
|
||||||
LazyColumn(
|
when (selectedTab) {
|
||||||
modifier = Modifier
|
0 -> {
|
||||||
.fillMaxSize()
|
// Stats tab - delegate to StatsScreen
|
||||||
.padding(paddingValues),
|
StatsScreen()
|
||||||
contentPadding = PaddingValues(16.dp),
|
}
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
1 -> {
|
||||||
) {
|
// Tools tab - existing utilities
|
||||||
// Section: Scan & Import
|
ToolsTabContent(
|
||||||
item {
|
uiState = uiState,
|
||||||
SectionHeader(
|
scanProgress = scanProgress,
|
||||||
title = "Scan & Import",
|
onScanPhotos = { viewModel.scanForPhotos() },
|
||||||
icon = Icons.Default.Scanner
|
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 {
|
item {
|
||||||
UtilityCard(
|
ProgressCard(scanProgress)
|
||||||
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() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Section: Organization
|
// Results
|
||||||
item {
|
when (val state = uiState) {
|
||||||
Spacer(Modifier.height(8.dp))
|
is UtilitiesUiState.ScanComplete -> {
|
||||||
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) {
|
|
||||||
item {
|
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
|
// Info card
|
||||||
when (val state = uiState) {
|
item {
|
||||||
is UtilitiesUiState.ScanComplete -> {
|
Spacer(Modifier.height(8.dp))
|
||||||
item {
|
InfoCard()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ gson = "2.10.1"
|
|||||||
#Album/Image View Tools
|
#Album/Image View Tools
|
||||||
zoomable = "1.6.1"
|
zoomable = "1.6.1"
|
||||||
|
|
||||||
|
#Charting Lib
|
||||||
|
vico = "2.0.0-alpha.28"
|
||||||
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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
|
#Album/Image View Tools
|
||||||
zoomable = { group = "net.engawapg.lib", name = "zoomable", version.ref = "zoomable" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user