Bruno Charest 7277342d4a docs: Comprehensive README update with new features and architecture details
- 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
2026-01-29 13:14:47 -05:00

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"
}
}
}