feat: [#10] composants UI réutilisables (TaskRow, PriorityBadge, DueDateChip, LabelChip, SectionHeader, EmptyState)

This commit is contained in:
2026-06-06 06:02:50 +02:00
parent c83a15c1b1
commit 4dfc224eb6
6 changed files with 380 additions and 0 deletions
@@ -0,0 +1,50 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun DueDateChip(dateIso: String, modifier: Modifier = Modifier) {
val date = runCatching { LocalDate.parse(dateIso.take(10)) }.getOrNull() ?: return
val today = LocalDate.now()
val overdue = date.isBefore(today)
val color = if (overdue) Color(0xFFE53935) else MaterialTheme.colorScheme.onSurfaceVariant
val label = when (date) {
today -> "Aujourd'hui"
today.plusDays(1) -> "Demain"
else -> date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.CalendarToday,
contentDescription = null,
tint = color,
modifier = Modifier.width(14.dp)
)
Spacer(Modifier.width(2.dp))
Text(text = label, style = MaterialTheme.typography.labelSmall, color = color)
}
}
@Preview
@Composable
private fun DueDateChipPreview() {
DueDateChip(dateIso = LocalDate.now().toString())
}
@@ -0,0 +1,67 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
icon: ImageVector,
title: String,
subtitle: String? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (subtitle != null) {
Spacer(Modifier.height(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun EmptyStatePreview() {
EmptyState(
icon = Icons.Outlined.CheckCircle,
title = "Aucune tâche",
subtitle = "Créez votre première tâche avec le bouton +",
)
}
@@ -0,0 +1,30 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun LabelChip(name: String, color: Color, modifier: Modifier = Modifier) {
Text(
text = name,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = modifier
.background(color.copy(alpha = 0.12f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
@Preview
@Composable
private fun LabelChipPreview() {
LabelChip(name = "android", color = Color(0xFF1E88E5))
}
@@ -0,0 +1,34 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
val priorityColor = mapOf(
1 to Color(0xFFE53935),
2 to Color(0xFFFB8C00),
3 to Color(0xFF1E88E5),
4 to Color(0xFF9E9E9E),
)
@Composable
fun PriorityBadge(priority: Int, modifier: Modifier = Modifier) {
val color = priorityColor[priority] ?: priorityColor[4]!!
Box(
modifier = modifier
.size(12.dp)
.background(color, CircleShape)
)
}
@Preview
@Composable
private fun PriorityBadgePreview() {
PriorityBadge(priority = 1)
}
@@ -0,0 +1,80 @@
package com.planify.mobile.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SectionHeader(
name: String,
taskCount: Int,
collapsed: Boolean,
onToggleCollapse: () -> Unit,
onAddTask: () -> Unit,
modifier: Modifier = Modifier,
) {
val rotation by animateFloatAsState(if (collapsed) -90f else 0f, label = "collapse")
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onToggleCollapse() }
.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = if (collapsed) "Déplier" else "Replier",
modifier = Modifier.rotate(rotation),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
if (taskCount > 0) {
Text(
text = "$taskCount",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onAddTask) {
Icon(Icons.Default.Add, contentDescription = "Ajouter une tâche")
}
}
}
@Preview(showBackground = true)
@Composable
private fun SectionHeaderPreview() {
SectionHeader(
name = "En cours",
taskCount = 3,
collapsed = false,
onToggleCollapse = {},
onAddTask = {},
)
}
@@ -0,0 +1,119 @@
package com.planify.mobile.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.planify.mobile.domain.model.Task
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TaskRow(
task: Task,
onCheckedChange: (Boolean) -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val priorityColor = priorityColor[task.priority] ?: Color.Gray
val textColor by animateColorAsState(
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
else MaterialTheme.colorScheme.onSurface,
label = "textColor",
)
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = {})
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = task.checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(
checkedColor = priorityColor,
uncheckedColor = priorityColor,
),
)
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.content,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (task.dueDate != null) {
DueDateChip(dateIso = task.dueDate.date)
}
task.labels.take(2).forEach { labelName ->
LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary)
}
}
}
if (task.priority < 4) {
Spacer(Modifier.width(8.dp))
PriorityBadge(priority = task.priority)
}
}
}
@Preview(showBackground = true)
@Composable
private fun TaskRowPreview() {
Surface {
Column {
TaskRow(
task = Task(
id = "1",
content = "Implémenter la navigation principale",
projectId = "p1",
priority = 2,
labels = listOf("android", "ui"),
),
onCheckedChange = {},
onClick = {},
)
Spacer(Modifier.height(1.dp))
TaskRow(
task = Task(
id = "2",
content = "Tâche terminée",
projectId = "p1",
priority = 4,
checked = true,
),
onCheckedChange = {},
onClick = {},
)
}
}
}