feat: implement full Bonsai design — bottom nav, green theme, redesigned screens

- Replace drawer + TopAppBar with bottom tab bar (Aujourd'hui / Prévu / Projets / Profil)
- Update theme to forest green (#2F7A4F) + warm cream (#F1ECE0) palette (light & dark)
- TodayScreen: header with date, hero progress ring showing done/total tasks
- ScheduledScreen: horizontal week strip with today highlighted
- ProjectScreen: full-width green banner with done/remaining/progress stats
- ProjectsListScreen: new screen with 2×2 quick tiles + project list with progress rings
- TaskRow: card-style with rounded border, circular checkbox
- Add getDoneTodayCount() to DAO/Repository/ViewModel for progress tracking
- Route.ProjectsList added; start destination changed to Today

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 14:13:36 +02:00
parent 33f95cc5a5
commit 27a3e569af
15 changed files with 1379 additions and 433 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ android {
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.0.17"
versionName = "0.0.18"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -32,6 +32,9 @@ interface TaskDao {
""")
fun getTodayTasks(): Flow<List<TaskEntity>>
@Query("SELECT COUNT(*) FROM tasks WHERE date(due_date) = date('now') AND checked = 1 AND is_deleted = 0")
fun getDoneTodayCount(): Flow<Int>
@Query("""
SELECT * FROM tasks
WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0
@@ -31,6 +31,8 @@ class TaskRepositoryImpl @Inject constructor(
override fun getTodayTasks(): Flow<List<Task>> =
dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
override fun getDoneTodayCount() = dao.getDoneTodayCount()
override fun getOverdueTasks(): Flow<List<Task>> =
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
@@ -8,6 +8,7 @@ interface TaskRepository {
fun getTasksBySection(sectionId: String): Flow<List<Task>>
fun getInboxTasks(): Flow<List<Task>>
fun getTodayTasks(): Flow<List<Task>>
fun getDoneTodayCount(): Flow<Int>
fun getOverdueTasks(): Flow<List<Task>>
fun getSubTasks(parentId: String): Flow<List<Task>>
suspend fun getTaskById(id: String): Task?
@@ -1,49 +1,29 @@
package com.planify.mobile.ui
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.ui.text.style.TextAlign
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.collectAsState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Today
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
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
@@ -55,170 +35,104 @@ import com.planify.mobile.ui.navigation.DrawerViewModel
import com.planify.mobile.ui.navigation.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route
import com.planify.mobile.ui.task.TaskEditSheet
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
private data class BottomTab(
val route: String,
val icon: ImageVector,
val label: String,
)
private val bottomTabs = listOf(
BottomTab(Route.Today.path, Icons.Outlined.Today, "Aujourd'hui"),
BottomTab(Route.Scheduled.path, Icons.Outlined.CalendarMonth, "Prévu"),
BottomTab(Route.ProjectsList.path, Icons.Outlined.GridView, "Projets"),
BottomTab(Route.Settings.path, Icons.Outlined.Person, "Profil"),
)
@Composable
fun MainScreen(
authViewModel: AuthViewModel,
viewModel: DrawerViewModel = hiltViewModel(),
drawerViewModel: DrawerViewModel = hiltViewModel(),
) {
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val projects by viewModel.projects.collectAsState()
val navBackStack by navController.currentBackStackEntryAsState()
val currentRoute = navBackStack?.destination?.route
var showCreateTask by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<Task?>(null) }
val projects by drawerViewModel.projects.collectAsState()
val inboxProjectId = projects.find { it.isInbox }?.id ?: ""
// destination.route is the pattern ("project/{projectId}"), arguments hold the real value
val createProjectId = if (currentRoute == Route.Project().path)
navBackStack?.arguments?.getString("projectId") ?: inboxProjectId
else
inboxProjectId
val drawerTitles = mapOf(
Route.Inbox.path to "Inbox",
Route.Today.path to "Aujourd'hui",
Route.Scheduled.path to "Planifié",
Route.Search.path to "Recherche",
Route.Filter.path to "Filtres",
Route.Settings.path to "Paramètres",
)
val activeProjectName = if (currentRoute == Route.Project().path)
projects.find { it.id == navBackStack?.arguments?.getString("projectId") }?.name
else null
val title = drawerTitles[currentRoute] ?: activeProjectName ?: "BonsaiTask"
val hideBottomBarRoutes = setOf<String>()
val showBottomBar = currentRoute !in hideBottomBarRoutes
val hideFabRoutes = setOf(Route.Settings.path)
val showFab = currentRoute !in hideFabRoutes
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
Text(
text = "BonsaiTask",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Inbox, null) },
label = { Text("Inbox") },
selected = currentRoute == Route.Inbox.path,
onClick = {
navController.navigate(Route.Inbox.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Today, null) },
label = { Text("Aujourd'hui") },
selected = currentRoute == Route.Today.path,
onClick = {
navController.navigate(Route.Today.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.CalendarMonth, null) },
label = { Text("Planifié") },
selected = currentRoute == Route.Scheduled.path,
onClick = {
navController.navigate(Route.Scheduled.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Search, null) },
label = { Text("Recherche") },
selected = currentRoute == Route.Search.path,
onClick = {
navController.navigate(Route.Search.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.FilterList, null) },
label = { Text("Filtres") },
selected = currentRoute == Route.Filter.path,
onClick = {
navController.navigate(Route.Filter.path)
scope.launch { drawerState.close() }
},
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
text = "Projets",
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn {
items(projects) { project ->
NavigationDrawerItem(
icon = { Icon(Icons.Default.FolderOpen, null) },
label = { Text(project.name) },
selected = currentRoute == Route.Project().path && navBackStack?.arguments?.getString("projectId") == project.id,
onClick = {
navController.navigate(Route.Project().buildRoute(project.id))
scope.launch { drawerState.close() }
},
)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Settings, null) },
label = { Text("Paramètres") },
selected = currentRoute == Route.Settings.path,
onClick = {
navController.navigate(Route.Settings.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Logout, null) },
label = { Text("Déconnexion") },
selected = false,
onClick = {
authViewModel.logout()
scope.launch { drawerState.close() }
},
)
Spacer(Modifier.height(8.dp))
Text(
text = "v${com.planify.mobile.BuildConfig.VERSION_NAME}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
}
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Outlined.Menu, contentDescription = "Menu")
bottomBar = {
if (showBottomBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
bottomTabs.forEach { tab ->
val selected = currentRoute == tab.route ||
(tab.route == Route.ProjectsList.path && currentRoute == Route.Project().path)
NavigationBarItem(
selected = selected,
onClick = {
if (currentRoute != tab.route) {
navController.navigate(tab.route) {
popUpTo(Route.Today.path) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
},
icon = {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
)
},
label = {
Text(
text = tab.label,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
),
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
},
floatingActionButton = {
if (currentRoute != Route.Settings.path) {
if (showFab) {
FloatingActionButton(
onClick = { showCreateTask = true },
containerColor = MaterialTheme.colorScheme.primary,
shape = CircleShape,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(20.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = "Nouvelle tâche", tint = MaterialTheme.colorScheme.onPrimary)
Icon(Icons.Outlined.Add, contentDescription = "Nouvelle tâche")
}
}
},
containerColor = MaterialTheme.colorScheme.background,
) { padding ->
PlanifyNavHost(
navController = navController,
@@ -242,5 +156,4 @@ fun MainScreen(
)
}
}
}
}
@@ -1,18 +1,26 @@
package com.planify.mobile.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -20,7 +28,9 @@ 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.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -35,40 +45,54 @@ fun TaskRow(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val priorityColor = priorityColor[task.priority] ?: Color.Gray
val checkColor = when {
task.checked -> MaterialTheme.colorScheme.primary
task.priority == 1 -> MaterialTheme.colorScheme.secondary
task.priority == 2 -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
task.priority == 3 -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
val textColor by animateColorAsState(
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
if (task.checked) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
label = "textColor",
)
Row(
Surface(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = {})
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 16.dp, vertical = 4.dp),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
tonalElevation = 0.dp,
) {
Checkbox(
Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircleCheckbox(
checked = task.checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(
checkedColor = priorityColor,
uncheckedColor = priorityColor,
),
color = checkColor,
onClick = { onCheckedChange(!task.checked) },
modifier = Modifier.padding(top = 1.dp),
)
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.content,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = textColor,
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val hasMeta = task.dueDate != null || task.labels.isNotEmpty()
if (hasMeta) {
Spacer(Modifier.height(5.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (task.dueDate != null) {
@@ -79,9 +103,34 @@ fun TaskRow(
}
}
}
if (task.priority < 4) {
Spacer(Modifier.width(8.dp))
PriorityBadge(priority = task.priority)
}
}
}
}
@Composable
fun CircleCheckbox(
checked: Boolean,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(22.dp)
.border(2.dp, color, CircleShape)
.background(if (checked) color else Color.Transparent, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (checked) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(13.dp),
)
}
}
}
@@ -89,7 +138,6 @@ fun TaskRow(
@Preview(showBackground = true)
@Composable
private fun TaskRowPreview() {
Surface {
Column {
TaskRow(
task = Task(
@@ -102,7 +150,6 @@ private fun TaskRowPreview() {
onCheckedChange = {},
onClick = {},
)
Spacer(Modifier.height(1.dp))
TaskRow(
task = Task(
id = "2",
@@ -115,5 +162,4 @@ private fun TaskRowPreview() {
onClick = {},
)
}
}
}
@@ -13,6 +13,7 @@ import com.planify.mobile.ui.filter.FilterScreen
import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen
import com.planify.mobile.ui.project.ProjectScreen
import com.planify.mobile.ui.project.ProjectsListScreen
import com.planify.mobile.ui.scheduled.ScheduledScreen
import com.planify.mobile.ui.search.SearchScreen
import com.planify.mobile.ui.settings.SettingsScreen
@@ -27,17 +28,41 @@ fun PlanifyNavHost(
) {
NavHost(
navController = navController,
startDestination = Route.Inbox.path,
startDestination = Route.Today.path,
modifier = modifier,
) {
composable(Route.Inbox.path) {
InboxScreen(onTaskClick = onTaskClick)
}
composable(Route.Today.path) {
TodayScreen(onTaskClick = onTaskClick)
}
composable(Route.Scheduled.path) {
ScheduledScreen(onTaskClick = onTaskClick)
}
composable(Route.ProjectsList.path) {
ProjectsListScreen(
onProjectClick = { projectId ->
navController.navigate(Route.Project().buildRoute(projectId))
},
onInboxClick = { navController.navigate(Route.Inbox.path) },
onTodayClick = {
navController.navigate(Route.Today.path) {
launchSingleTop = true
}
},
onScheduledClick = {
navController.navigate(Route.Scheduled.path) {
launchSingleTop = true
}
},
onLabelsClick = { navController.navigate(Route.Filter.path) },
)
}
composable(Route.Inbox.path) {
InboxScreen(onTaskClick = onTaskClick)
}
composable(
route = Route.Project().path,
arguments = listOf(navArgument("projectId") { type = NavType.StringType })
@@ -50,10 +75,6 @@ fun PlanifyNavHost(
)
}
composable(Route.Scheduled.path) {
ScheduledScreen(onTaskClick = onTaskClick)
}
composable(Route.Search.path) {
SearchScreen(onTaskClick = onTaskClick)
}
@@ -6,6 +6,7 @@ sealed class Route(val path: String) {
data object Scheduled : Route("scheduled")
data object Search : Route("search")
data object Filter : Route("filter")
data object ProjectsList : Route("projects")
data class Project(val projectId: String = "{projectId}") :
Route("project/{projectId}") {
fun buildRoute(id: String) = "project/$id"
@@ -1,21 +1,33 @@
package com.planify.mobile.ui.project
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
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
@@ -25,7 +37,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle
@@ -49,14 +65,27 @@ fun ProjectScreen(
val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
// Green project banner
ProjectBanner(
projectName = state.project?.name ?: "",
totalTasks = state.sections.sumOf { it.tasks.size },
doneTasks = state.sections.sumOf { s -> s.tasks.count { it.checked } },
onBack = onBack,
)
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
EmptyState(
icon = Icons.Outlined.FolderOpen,
title = "Projet vide",
subtitle = "Créez votre première tâche avec le bouton +",
modifier = modifier,
modifier = Modifier.weight(1f),
)
return
return@Column
}
when (state.viewStyle) {
@@ -71,13 +100,139 @@ fun ProjectScreen(
onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) },
onReorder = { viewModel.reorderTasks(it) },
modifier = modifier,
modifier = Modifier.weight(1f),
)
ViewStyle.BOARD -> ProjectBoardView(
state = state,
onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) },
modifier = modifier,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun ProjectBanner(
projectName: String,
totalTasks: Int,
doneTasks: Int,
onBack: () -> Unit,
) {
val progress = if (totalTasks == 0) 0f else doneTasks.toFloat() / totalTasks
val remainingTasks = totalTasks - doneTasks
val primaryColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = primaryColor,
shape = RoundedCornerShape(bottomStart = 26.dp, bottomEnd = 26.dp),
)
.padding(horizontal = 18.dp, vertical = 16.dp),
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.background(Color.White.copy(alpha = 0.18f), CircleShape)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = onBack) {
Icon(
Icons.Outlined.ArrowBack,
contentDescription = "Retour",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.weight(1f))
Box(
modifier = Modifier
.size(40.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(13.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.FolderOpen,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(40.dp)
.background(Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = "Plus",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.height(16.dp))
Text(
text = projectName,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color.White,
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp)) {
BannerStat(value = "$doneTasks", label = "Terminées")
BannerStat(value = "$remainingTasks", label = "Restantes")
if (totalTasks > 0) {
BannerStat(value = "${(progress * 100).toInt()}%", label = "Avancement")
}
}
if (totalTasks > 0) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(6.dp)),
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.height(6.dp)
.background(Color.White, RoundedCornerShape(6.dp)),
)
}
}
}
}
}
@Composable
private fun BannerStat(value: String, label: String) {
Column {
Text(
text = value,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color.White,
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Medium),
color = Color.White.copy(alpha = 0.82f),
)
}
}
@@ -95,7 +250,11 @@ private fun ProjectListView(
val listState = rememberLazyListState()
val reorderState = rememberReorderState()
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 96.dp),
) {
state.sections.forEach { group ->
val key = group.section?.id ?: "unsectioned"
val name = group.section?.name ?: "Sans section"
@@ -0,0 +1,378 @@
package com.planify.mobile.ui.project
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@Composable
fun ProjectsListScreen(
onProjectClick: (String) -> Unit,
onInboxClick: () -> Unit,
onTodayClick: () -> Unit,
onScheduledClick: () -> Unit,
onLabelsClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ProjectsListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
) {
// Header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Mon espace",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.4.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Projets",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
Box(
modifier = Modifier
.size(38.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.clip(CircleShape)
.clickable(onClick = onLabelsClick),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.Search,
contentDescription = "Recherche",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
// 2×2 quick-access tiles
item {
val tiles = listOf(
Triple(Icons.Outlined.Inbox, "Boîte de réception",
"${state.inboxProject?.let { "tâches" } ?: "0 tâche"}"),
Triple(Icons.Outlined.Today, "Aujourd'hui",
"${state.todayCount} tâche${if (state.todayCount != 1) "s" else ""}"),
Triple(Icons.Outlined.CalendarMonth, "Prévu",
"${state.scheduledCount} à venir"),
Triple(Icons.Outlined.Label, "Étiquettes", ""),
)
val tileActions = listOf(onInboxClick, onTodayClick, onScheduledClick, onLabelsClick)
val tileColors = listOf(
Color(0xFF5285AE),
MaterialTheme.colorScheme.primary,
Color(0xFF8A6BB0),
Color(0xFFC2683C),
)
androidx.compose.foundation.layout.Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
listOf(0..1, 2..3).forEach { range ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
range.forEach { idx ->
val (icon, name, count) = tiles[idx]
QuickTile(
icon = icon,
name = name,
count = count,
iconColor = tileColors[idx],
onClick = tileActions[idx],
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
// "Mes projets" section header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 22.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "MES PROJETS",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (state.projects.isNotEmpty()) {
Box(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(20.dp),
)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = "${state.projects.size}",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// Project list card
if (state.projects.isNotEmpty()) {
item {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outlineVariant
),
) {
Column {
state.projects.forEachIndexed { idx, projectStats ->
ProjectListRow(
projectStats = projectStats,
onClick = { onProjectClick(projectStats.project.id) },
showDivider = idx < state.projects.lastIndex,
)
}
}
}
}
} else {
item {
Text(
text = "Aucun projet pour l'instant",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
)
}
}
}
}
@Composable
private fun QuickTile(
icon: ImageVector,
name: String,
count: String,
iconColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outlineVariant
),
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.background(iconColor, RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(17.dp),
)
}
Column {
Text(
text = name,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
)
if (count.isNotEmpty()) {
Text(
text = count,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun ProjectListRow(
projectStats: ProjectWithStats,
onClick: () -> Unit,
showDivider: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
ProjectProgressRing(
progress = projectStats.progress,
size = 38.dp,
strokeWidth = 4.dp,
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = projectStats.project.name,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
)
if (projectStats.totalTasks > 0) {
Text(
text = "${projectStats.totalTasks} tâche${if (projectStats.totalTasks != 1) "s" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (projectStats.totalTasks > 0) {
Text(
text = "${projectStats.doneTasks}/${projectStats.totalTasks}",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showDivider) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.padding(horizontal = 14.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
}
}
@Composable
fun ProjectProgressRing(
progress: Float,
size: Dp,
strokeWidth: Dp,
color: Color,
trackColor: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.size(size)) {
val sw = strokeWidth.toPx()
val diameter = this.size.minDimension - sw
val topLeft = Offset(sw / 2, sw / 2)
val arcSize = Size(diameter, diameter)
drawArc(
color = trackColor,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = topLeft,
size = arcSize,
)
if (progress > 0f) {
drawArc(
color = color,
startAngle = -90f,
sweepAngle = progress * 360f,
useCenter = false,
style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = topLeft,
size = arcSize,
)
}
}
}
@@ -0,0 +1,63 @@
package com.planify.mobile.ui.project
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
data class ProjectWithStats(
val project: Project,
val totalTasks: Int,
val doneTasks: Int,
) {
val progress: Float get() = if (totalTasks == 0) 0f else doneTasks.toFloat() / totalTasks
}
data class ProjectsListUiState(
val projects: List<ProjectWithStats> = emptyList(),
val inboxProject: Project? = null,
val todayCount: Int = 0,
val scheduledCount: Int = 0,
)
@HiltViewModel
class ProjectsListViewModel @Inject constructor(
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
) : ViewModel() {
val uiState = combine(
projectRepository.getAllProjects(),
taskRepository.getAllTasks(),
taskRepository.getTodayTasks(),
taskRepository.getScheduledTasks(),
) { projects, allTasks, todayTasks, scheduledTasks ->
val tasksByProject = allTasks.groupBy { it.projectId }
val inboxProject = projects.find { it.isInbox }
val regularProjects = projects.filter { !it.isInbox }
val withStats = regularProjects.map { project ->
val tasks = tasksByProject[project.id] ?: emptyList()
ProjectWithStats(
project = project,
totalTasks = tasks.size,
doneTasks = tasks.count { it.checked },
)
}
ProjectsListUiState(
projects = withStats,
inboxProject = inboxProject,
todayCount = todayTasks.size,
scheduledCount = scheduledTasks.size,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProjectsListUiState(),
)
}
@@ -1,9 +1,20 @@
package com.planify.mobile.ui.scheduled
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material3.MaterialTheme
@@ -11,38 +22,54 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.TextStyle
import java.util.Locale
@Composable
fun ScheduledScreen(
onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier,
viewModel: ScheduledViewModel = hiltViewModel(),
) {
val groups by viewModel.groups.collectAsState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(bottom = 96.dp),
) {
// Header
item { ScheduledHeader() }
// Week strip
item { WeekStrip() }
if (groups.isEmpty()) {
item {
EmptyState(
icon = Icons.Outlined.CalendarMonth,
title = "Aucune tâche planifiée",
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
)
return
}
return@LazyColumn
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
groups.forEach { group ->
item(key = group.label) {
Text(
text = group.label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
item(key = "head_${group.label}") {
DayGroupHeader(label = group.label, count = group.tasks.size)
}
items(group.tasks, key = { it.id }) { task ->
TaskRow(
@@ -54,3 +81,133 @@ fun ScheduledScreen(
}
}
}
@Composable
private fun ScheduledHeader() {
val today = LocalDate.now()
val monthFormatter = java.time.format.DateTimeFormatter.ofPattern("MMMM yyyy", Locale.FRENCH)
val monthStr = today.format(monthFormatter).replaceFirstChar { it.uppercaseChar() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = monthStr,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.4.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Prévu",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
}
}
@Composable
private fun WeekStrip() {
val today = LocalDate.now()
// Find Monday of the current week
val monday = today.with(DayOfWeek.MONDAY)
val days = (0..6).map { monday.plusDays(it.toLong()) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
days.forEach { day ->
val isToday = day == today
WeekDay(
dayShort = day.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.FRENCH)
.take(3).uppercase(),
dayNum = day.dayOfMonth,
isToday = isToday,
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun WeekDay(dayShort: String, dayNum: Int, isToday: Boolean, modifier: Modifier = Modifier) {
val bgColor = if (isToday) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.surface
val textColor = if (isToday) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface
val faintColor = if (isToday) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant
Box(
modifier = modifier
.border(
width = 1.dp,
color = if (isToday) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(16.dp),
)
.background(bgColor, RoundedCornerShape(16.dp))
.padding(vertical = 9.dp),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = dayShort,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 0.4.sp,
),
color = faintColor,
)
Text(
text = "$dayNum",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = textColor,
)
// dot placeholder (could indicate tasks on that day)
Box(
modifier = Modifier
.size(5.dp)
.background(
if (isToday) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f)
else MaterialTheme.colorScheme.background,
CircleShape,
),
)
}
}
}
@Composable
private fun DayGroupHeader(label: String, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onBackground,
)
Text(
text = "· $count tâche${if (count > 1) "s" else ""}",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -11,93 +11,105 @@ import androidx.compose.ui.graphics.Color
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode
// Bonsai design tokens — extracted from the webapp CSS palette
private val Blue600 = Color(0xFF2563EB)
private val Blue700 = Color(0xFF1D4ED8)
private val Blue800 = Color(0xFF1E3A8A)
private val Blue100 = Color(0xFFDBEAFE)
private val Blue50 = Color(0xFFEFF6FF)
private val Blue300 = Color(0xFF93C5FD)
private val Blue400 = Color(0xFF60A5FA)
// Bonsai design tokens — warm cream + forest green palette
private val Gray50 = Color(0xFFF9FAFB)
private val Gray100 = Color(0xFFF3F4F6)
private val Gray200 = Color(0xFFE5E7EB)
private val Gray300 = Color(0xFFD1D5DB)
private val Gray400 = Color(0xFF9CA3AF)
private val Gray500 = Color(0xFF6B7280)
private val Gray600 = Color(0xFF4B5563)
private val Gray700 = Color(0xFF374151)
private val Gray800 = Color(0xFF1F2937)
private val Gray900 = Color(0xFF111827)
// Light
private val Green_2F7A4F = Color(0xFF2F7A4F)
private val Green_1E5E3C = Color(0xFF1E5E3C)
private val Green_DCEAD8 = Color(0xFFDCEAD8)
private val Green_2C6A45 = Color(0xFF2C6A45)
private val Green800 = Color(0xFF276749)
private val Green600 = Color(0xFF2F855A)
private val Green200 = Color(0xFFD1FAE5)
private val Green900 = Color(0xFF14532D)
private val Cream_F1ECE0 = Color(0xFFF1ECE0)
private val Cream_FBF8F1 = Color(0xFFFBF8F1)
private val Cream_EBE5D6 = Color(0xFFEBE5D6)
private val Red600 = Color(0xFFDC2626)
private val Red100 = Color(0xFFFEE2E2)
private val Red900 = Color(0xFF7F1D1D)
private val Ink_22291F = Color(0xFF22291F)
private val Ink_6A7163 = Color(0xFF6A7163)
private val Line_E4DCC9 = Color(0xFFE4DCC9)
private val Line_EFE9DB = Color(0xFFEFE9DB)
private val Terra_C2683C = Color(0xFFC2683C)
private val Terra_F2DECE = Color(0xFFF2DECE)
private val Terra_9E5026 = Color(0xFF9E5026)
// Dark
private val DGreen_74C58A = Color(0xFF74C58A)
private val DGreen_4FA268 = Color(0xFF4FA268)
private val DGreen_1F3422 = Color(0xFF1F3422)
private val DGreen_9BD9AC = Color(0xFF9BD9AC)
private val DGreen_08140C = Color(0xFF08140C)
private val DBg_10190F = Color(0xFF10190F)
private val DSurf_18241A = Color(0xFF18241A)
private val DSurf2_1F2E21 = Color(0xFF1F2E21)
private val DInk_EAF0E3 = Color(0xFFEAF0E3)
private val DInk_9DAE9C = Color(0xFF9DAE9C)
private val DLine_27361F = Color(0xFF27361F)
private val DLine_1E2D1A = Color(0xFF1E2D1A)
private val DTerra_E0905E = Color(0xFFE0905E)
private val DTerra_33231A = Color(0xFF33231A)
private val DTerra_E9A579 = Color(0xFFE9A579)
private val BonsaiLightColorScheme = lightColorScheme(
primary = Blue600,
onPrimary = Color.White,
primaryContainer = Blue100,
onPrimaryContainer = Blue800,
secondary = Blue700,
primary = Green_2F7A4F,
onPrimary = Cream_FBF8F1,
primaryContainer = Green_DCEAD8,
onPrimaryContainer = Green_2C6A45,
secondary = Terra_C2683C,
onSecondary = Color.White,
secondaryContainer = Blue50,
onSecondaryContainer = Blue700,
tertiary = Green800,
onTertiary = Color.White,
tertiaryContainer = Green200,
onTertiaryContainer = Green900,
error = Red600,
secondaryContainer = Terra_F2DECE,
onSecondaryContainer = Terra_9E5026,
tertiary = Green_1E5E3C,
onTertiary = Cream_FBF8F1,
tertiaryContainer = Green_DCEAD8,
onTertiaryContainer = Green_2C6A45,
error = Terra_C2683C,
onError = Color.White,
errorContainer = Red100,
onErrorContainer = Red900,
background = Gray50,
onBackground = Gray900,
surface = Color.White,
onSurface = Gray900,
surfaceVariant = Gray100,
onSurfaceVariant = Gray700,
outline = Gray200,
outlineVariant = Gray300,
inverseSurface = Gray800,
inverseOnSurface = Gray50,
inversePrimary = Blue300,
errorContainer = Terra_F2DECE,
onErrorContainer = Terra_9E5026,
background = Cream_F1ECE0,
onBackground = Ink_22291F,
surface = Cream_FBF8F1,
onSurface = Ink_22291F,
surfaceVariant = Cream_EBE5D6,
onSurfaceVariant = Ink_6A7163,
outline = Line_E4DCC9,
outlineVariant = Line_EFE9DB,
inverseSurface = Ink_22291F,
inverseOnSurface = Cream_FBF8F1,
inversePrimary = Green_DCEAD8,
)
private val BonsaiDarkColorScheme = darkColorScheme(
primary = Blue300,
onPrimary = Blue800,
primaryContainer = Blue700,
onPrimaryContainer = Blue100,
secondary = Blue400,
onSecondary = Blue800,
secondaryContainer = Blue800,
onSecondaryContainer = Blue100,
tertiary = Green200,
onTertiary = Green900,
tertiaryContainer = Green600,
onTertiaryContainer = Green200,
error = Color(0xFFF87171),
onError = Red900,
errorContainer = Color(0xFF991B1B),
onErrorContainer = Color(0xFFFECACA),
background = Gray900,
onBackground = Gray50,
surface = Gray800,
onSurface = Gray50,
surfaceVariant = Gray700,
onSurfaceVariant = Gray300,
outline = Gray600,
outlineVariant = Gray700,
inverseSurface = Gray50,
inverseOnSurface = Gray900,
inversePrimary = Blue600,
primary = DGreen_74C58A,
onPrimary = DGreen_08140C,
primaryContainer = DGreen_1F3422,
onPrimaryContainer = DGreen_9BD9AC,
secondary = DTerra_E0905E,
onSecondary = DGreen_08140C,
secondaryContainer = DTerra_33231A,
onSecondaryContainer = DTerra_E9A579,
tertiary = DGreen_4FA268,
onTertiary = DGreen_08140C,
tertiaryContainer = DGreen_1F3422,
onTertiaryContainer = DGreen_9BD9AC,
error = DTerra_E0905E,
onError = DGreen_08140C,
errorContainer = DTerra_33231A,
onErrorContainer = DTerra_E9A579,
background = DBg_10190F,
onBackground = DInk_EAF0E3,
surface = DSurf_18241A,
onSurface = DInk_EAF0E3,
surfaceVariant = DSurf2_1F2E21,
onSurfaceVariant = DInk_9DAE9C,
outline = DLine_27361F,
outlineVariant = DLine_1E2D1A,
inverseSurface = DInk_EAF0E3,
inverseOnSurface = DBg_10190F,
inversePrimary = Green_2F7A4F,
)
@Composable
@@ -1,27 +1,54 @@
package com.planify.mobile.ui.today
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable
fun TodayScreen(
@@ -32,30 +59,37 @@ fun TodayScreen(
val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(bottom = 96.dp),
) {
item { TodayHeader() }
if (state.totalCount > 0) {
item {
HeroCard(
doneCount = state.doneCount,
totalCount = state.totalCount,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
)
}
}
if (state.totalCount == 0 && !state.isLoading) {
item {
EmptyState(
icon = Icons.Outlined.Today,
title = "Rien pour aujourd'hui",
subtitle = "Profitez de votre journée !",
modifier = modifier,
)
return
}
return@LazyColumn
}
LazyColumn(modifier = modifier.fillMaxSize()) {
if (state.overdueTasks.isNotEmpty()) {
item {
SectionHeader(
name = "En retard",
taskCount = state.overdueTasks.size,
collapsed = "overdue" in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle("overdue")
},
onAddTask = {},
)
}
if ("overdue" !in collapsedSections.value) {
item { SectionLabel(name = "En retard", count = state.overdueTasks.size) }
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
TaskRow(
task = task,
@@ -64,22 +98,11 @@ fun TodayScreen(
)
}
}
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
}
state.tasksByProject.forEach { (projectName, tasks) ->
item(key = "header_$projectName") {
SectionHeader(
name = projectName,
taskCount = tasks.size,
collapsed = projectName in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle(projectName)
},
onAddTask = {},
)
SectionLabel(name = projectName, count = tasks.size)
}
if (projectName !in collapsedSections.value) {
items(tasks, key = { it.id }) { task ->
TaskRow(
task = task,
@@ -89,8 +112,171 @@ fun TodayScreen(
}
}
}
}
@Composable
private fun TodayHeader() {
val today = LocalDate.now()
val dayFormatter = DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.FRENCH)
val dateStr = today.format(dayFormatter).replaceFirstChar { it.uppercaseChar() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(34.dp)
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Text(text = "🌿", style = MaterialTheme.typography.bodyLarge)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = dateStr,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.4.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Aujourd'hui",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
Box(
modifier = Modifier
.size(38.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.Search,
contentDescription = "Recherche",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
private fun Set<String>.toggle(key: String) =
if (contains(key)) minus(key) else plus(key)
@Composable
private fun HeroCard(doneCount: Int, totalCount: Int, modifier: Modifier = Modifier) {
val progress = if (totalCount == 0) 0f else doneCount.toFloat() / totalCount
val remaining = totalCount - doneCount
val subtitle = when {
totalCount == 0 -> "Journée libre !"
doneCount == totalCount -> "Toutes les tâches sont faites !"
progress >= 0.5f -> "Tu y es presque !"
doneCount > 0 -> "${doneCount} faite${if (doneCount > 1) "s" else ""}. Encore $remaining pour boucler la journée."
else -> "$remaining tâche${if (remaining > 1) "s" else ""} pour aujourd'hui."
}
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
tonalElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
HeroRing(progress = progress, doneCount = doneCount, totalCount = totalCount)
Column {
Text(
text = if (doneCount == totalCount && totalCount > 0)
"Journée bouclée !" else "Ta journée avance bien",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun HeroRing(progress: Float, doneCount: Int, totalCount: Int) {
val primaryColor = MaterialTheme.colorScheme.primary
val trackColor = MaterialTheme.colorScheme.surfaceVariant
Box(modifier = Modifier.size(74.dp), contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.size(74.dp)) {
val sw = 7.dp.toPx()
val diameter = this.size.minDimension - sw
val tl = Offset(sw / 2, sw / 2)
val arcSize = Size(diameter, diameter)
drawArc(
color = trackColor, startAngle = -90f, sweepAngle = 360f,
useCenter = false, style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = tl, size = arcSize,
)
if (progress > 0f) {
drawArc(
color = primaryColor, startAngle = -90f, sweepAngle = progress * 360f,
useCenter = false, style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = tl, size = arcSize,
)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (totalCount == 0) "0" else "$doneCount/$totalCount",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "faites",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SectionLabel(name: String, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 18.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = name.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(20.dp))
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = "$count",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -16,6 +16,7 @@ data class TodayUiState(
val tasksByProject: Map<String, List<Task>> = emptyMap(),
val overdueTasks: List<Task> = emptyList(),
val totalCount: Int = 0,
val doneCount: Int = 0,
val isLoading: Boolean = false,
)
@@ -28,15 +29,18 @@ class TodayViewModel @Inject constructor(
val uiState = combine(
taskRepository.getTodayTasks(),
taskRepository.getOverdueTasks(),
taskRepository.getDoneTodayCount(),
projectRepository.getAllProjects(),
) { today, overdue, projects ->
) { today, overdue, done, projects ->
val projectMap = projects.associateBy { it.id }
val grouped = today
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
TodayUiState(
tasksByProject = grouped,
overdueTasks = overdue,
totalCount = today.size + overdue.size,
totalCount = today.size + overdue.size + done,
doneCount = done,
isLoading = false,
)
}.stateIn(
scope = viewModelScope,