- Add extensive documentation for offline mode, sync, collections, and dashboard features - Document new Material You theming, OLED mode, and widget capabilities - Include detailed sections on metadata extraction, Markdown editor, and import/export - Update technology stack versions (Kotlin 2.0.0, Hilt 2.51.1, Retrofit 2.11.0, Room 2.6.1) - Simplify installation and compilation instructions - Add user guide with first-time setup and
475 lines
15 KiB
Kotlin
475 lines
15 KiB
Kotlin
package com.shaarit.presentation.dashboard
|
|
|
|
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.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.unit.dp
|
|
import androidx.hilt.navigation.compose.hiltViewModel
|
|
import com.shaarit.data.local.entity.ContentType
|
|
import java.text.NumberFormat
|
|
import java.util.concurrent.TimeUnit
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@Composable
|
|
fun DashboardScreen(
|
|
onNavigateBack: () -> Unit,
|
|
viewModel: DashboardViewModel = hiltViewModel()
|
|
) {
|
|
val stats by viewModel.stats.collectAsState()
|
|
val tagStats by viewModel.tagStats.collectAsState()
|
|
val contentTypeStats by viewModel.contentTypeStats.collectAsState()
|
|
val activityData by viewModel.activityData.collectAsState()
|
|
|
|
Scaffold(
|
|
topBar = {
|
|
TopAppBar(
|
|
title = { Text("Tableau de bord") },
|
|
navigationIcon = {
|
|
IconButton(onClick = onNavigateBack) {
|
|
Icon(Icons.Default.ArrowBack, contentDescription = "Retour")
|
|
}
|
|
},
|
|
actions = {
|
|
IconButton(onClick = { viewModel.refreshStats() }) {
|
|
Icon(Icons.Default.Refresh, contentDescription = "Rafraîchir")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
) { padding ->
|
|
LazyColumn(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.padding(padding),
|
|
contentPadding = PaddingValues(16.dp),
|
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
) {
|
|
// Stats Cards Row
|
|
item {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
) {
|
|
StatCard(
|
|
title = "Total",
|
|
value = formatNumber(stats.totalLinks),
|
|
icon = Icons.Default.Bookmark,
|
|
color = MaterialTheme.colorScheme.primary,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
StatCard(
|
|
title = "Cette semaine",
|
|
value = formatNumber(stats.linksThisWeek),
|
|
icon = Icons.Default.Today,
|
|
color = MaterialTheme.colorScheme.secondary,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
StatCard(
|
|
title = "Ce mois",
|
|
value = formatNumber(stats.linksThisMonth),
|
|
icon = Icons.Default.DateRange,
|
|
color = MaterialTheme.colorScheme.tertiary,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Reading Time Stats
|
|
item {
|
|
ReadingTimeCard(
|
|
totalReadingTimeMinutes = stats.totalReadingTimeMinutes,
|
|
averageReadingTimeMinutes = stats.averageReadingTimeMinutes
|
|
)
|
|
}
|
|
|
|
// Content Type Distribution
|
|
item {
|
|
ContentTypeCard(contentTypeStats)
|
|
}
|
|
|
|
// Top Tags
|
|
item {
|
|
TopTagsCard(tagStats)
|
|
}
|
|
|
|
// Activity Overview
|
|
item {
|
|
ActivityCard(activityData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun StatCard(
|
|
title: String,
|
|
value: String,
|
|
icon: ImageVector,
|
|
color: Color,
|
|
modifier: Modifier = Modifier
|
|
) {
|
|
Card(
|
|
modifier = modifier,
|
|
colors = CardDefaults.cardColors(
|
|
containerColor = color.copy(alpha = 0.1f)
|
|
)
|
|
) {
|
|
Column(
|
|
modifier = Modifier
|
|
.padding(12.dp)
|
|
.fillMaxWidth(),
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Icon(
|
|
imageVector = icon,
|
|
contentDescription = null,
|
|
tint = color,
|
|
modifier = Modifier.size(24.dp)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = value,
|
|
style = MaterialTheme.typography.titleLarge,
|
|
fontWeight = FontWeight.Bold,
|
|
color = color
|
|
)
|
|
Text(
|
|
text = title,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ReadingTimeCard(
|
|
totalReadingTimeMinutes: Int,
|
|
averageReadingTimeMinutes: Int
|
|
) {
|
|
Card {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(16.dp)
|
|
) {
|
|
Text(
|
|
text = "Statistiques de lecture",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceEvenly
|
|
) {
|
|
ReadingTimeItem(
|
|
label = "Temps total",
|
|
minutes = totalReadingTimeMinutes,
|
|
icon = Icons.Default.Schedule
|
|
)
|
|
ReadingTimeItem(
|
|
label = "Moyenne/article",
|
|
minutes = averageReadingTimeMinutes,
|
|
icon = Icons.Default.Timer
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ReadingTimeItem(
|
|
label: String,
|
|
minutes: Int,
|
|
icon: ImageVector
|
|
) {
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally
|
|
) {
|
|
Icon(
|
|
imageVector = icon,
|
|
contentDescription = null,
|
|
tint = MaterialTheme.colorScheme.primary
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = formatDuration(minutes),
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Text(
|
|
text = label,
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ContentTypeCard(
|
|
contentTypeStats: Map<ContentType, Int>
|
|
) {
|
|
Card {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(16.dp)
|
|
) {
|
|
Text(
|
|
text = "Liens par type",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
|
|
val total = contentTypeStats.values.sum().coerceAtLeast(1)
|
|
|
|
for ((type, count) in contentTypeStats.toList().sortedByDescending { it.second }) {
|
|
val percentage = (count * 100) / total
|
|
ContentTypeBar(
|
|
type = type,
|
|
count = count,
|
|
percentage = percentage
|
|
)
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ContentTypeBar(
|
|
type: ContentType,
|
|
count: Int,
|
|
percentage: Int
|
|
) {
|
|
val (icon, label, color) = when (type) {
|
|
ContentType.ARTICLE -> Triple(Icons.Default.Article, "Article", Color(0xFF4CAF50))
|
|
ContentType.VIDEO -> Triple(Icons.Default.PlayCircle, "Vidéo", Color(0xFFF44336))
|
|
ContentType.IMAGE -> Triple(Icons.Default.Image, "Image", Color(0xFF9C27B0))
|
|
ContentType.PODCAST -> Triple(Icons.Default.Audiotrack, "Podcast", Color(0xFFFF9800))
|
|
ContentType.PDF -> Triple(Icons.Default.PictureAsPdf, "PDF", Color(0xFFE91E63))
|
|
ContentType.REPOSITORY -> Triple(Icons.Default.Code, "Code", Color(0xFF607D8B))
|
|
ContentType.DOCUMENT -> Triple(Icons.Default.Description, "Document", Color(0xFF795548))
|
|
ContentType.SOCIAL -> Triple(Icons.Default.Share, "Social", Color(0xFF03A9F4))
|
|
ContentType.SHOPPING -> Triple(Icons.Default.ShoppingCart, "Shopping", Color(0xFF2196F3))
|
|
ContentType.NEWSLETTER -> Triple(Icons.Default.Email, "Newsletter", Color(0xFF9C27B0))
|
|
ContentType.UNKNOWN -> Triple(Icons.Default.Link, "Autre", Color(0xFF9E9E9E))
|
|
}
|
|
|
|
Column {
|
|
Row(
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Icon(
|
|
imageVector = icon,
|
|
contentDescription = null,
|
|
tint = color,
|
|
modifier = Modifier.size(20.dp)
|
|
)
|
|
Spacer(modifier = Modifier.width(8.dp))
|
|
Text(
|
|
text = label,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
modifier = Modifier.weight(1f)
|
|
)
|
|
Text(
|
|
text = "$count ($percentage%)",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
LinearProgressIndicator(
|
|
progress = percentage / 100f,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
color = color,
|
|
trackColor = color.copy(alpha = 0.2f)
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun TopTagsCard(
|
|
tagStats: List<TagStat>
|
|
) {
|
|
Card {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(16.dp)
|
|
) {
|
|
Text(
|
|
text = "Tags les plus utilisés",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Spacer(modifier = Modifier.height(12.dp))
|
|
|
|
if (tagStats.isEmpty()) {
|
|
Text(
|
|
text = "Aucun tag pour le moment",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
} else {
|
|
tagStats.take(10).forEach { stat ->
|
|
TagStatRow(stat)
|
|
if (stat != tagStats.take(10).last()) {
|
|
Divider(modifier = Modifier.padding(vertical = 8.dp))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun TagStatRow(stat: TagStat) {
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically
|
|
) {
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.primaryContainer,
|
|
shape = MaterialTheme.shapes.small
|
|
) {
|
|
Text(
|
|
text = stat.name,
|
|
style = MaterialTheme.typography.labelMedium,
|
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.weight(1f))
|
|
Text(
|
|
text = "${stat.count} lien${if (stat.count > 1) "s" else ""}",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ActivityCard(
|
|
activityData: List<ActivityPoint>
|
|
) {
|
|
Card {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(16.dp)
|
|
) {
|
|
Text(
|
|
text = "Activité (30 derniers jours)",
|
|
style = MaterialTheme.typography.titleMedium,
|
|
fontWeight = FontWeight.Bold
|
|
)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
|
|
if (activityData.isEmpty()) {
|
|
Text(
|
|
text = "Pas d'activité récente",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
} else {
|
|
ActivityChart(activityData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
private fun ActivityChart(
|
|
data: List<ActivityPoint>
|
|
) {
|
|
val maxValue = data.maxOfOrNull { it.count }?.coerceAtLeast(1) ?: 1
|
|
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(100.dp),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
verticalAlignment = Alignment.Bottom
|
|
) {
|
|
data.takeLast(14).forEach { point ->
|
|
val heightFraction = point.count.toFloat() / maxValue
|
|
Column(
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
modifier = Modifier.weight(1f)
|
|
) {
|
|
Box(
|
|
modifier = Modifier
|
|
.width(8.dp)
|
|
.fillMaxHeight(heightFraction.coerceAtLeast(0.05f))
|
|
.background(
|
|
color = MaterialTheme.colorScheme.primary,
|
|
shape = MaterialTheme.shapes.extraSmall
|
|
)
|
|
)
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Text(
|
|
text = point.day.substring(0, 1),
|
|
style = MaterialTheme.typography.labelSmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Data classes
|
|
@Immutable
|
|
data class DashboardStats(
|
|
val totalLinks: Int = 0,
|
|
val linksThisWeek: Int = 0,
|
|
val linksThisMonth: Int = 0,
|
|
val totalReadingTimeMinutes: Int = 0,
|
|
val averageReadingTimeMinutes: Int = 0
|
|
)
|
|
|
|
@Immutable
|
|
data class TagStat(
|
|
val name: String,
|
|
val count: Int
|
|
)
|
|
|
|
@Immutable
|
|
data class ActivityPoint(
|
|
val day: String,
|
|
val count: Int
|
|
)
|
|
|
|
// Helper functions
|
|
private fun formatNumber(number: Int): String {
|
|
return NumberFormat.getInstance().format(number)
|
|
}
|
|
|
|
private fun formatDuration(minutes: Int): String {
|
|
return when {
|
|
minutes < 60 -> "${minutes}m"
|
|
minutes < 1440 -> {
|
|
val hours = minutes / 60
|
|
val mins = minutes % 60
|
|
if (mins > 0) "${hours}h ${mins}m" else "${hours}h"
|
|
}
|
|
else -> {
|
|
val days = minutes / 1440
|
|
"${days}j"
|
|
}
|
|
}
|
|
}
|