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 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.0.17" versionName = "0.0.18"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -32,6 +32,9 @@ interface TaskDao {
""") """)
fun getTodayTasks(): Flow<List<TaskEntity>> 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(""" @Query("""
SELECT * FROM tasks SELECT * FROM tasks
WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0 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>> = override fun getTodayTasks(): Flow<List<Task>> =
dao.getTodayTasks().map { it.map { e -> e.toDomain() } } dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
override fun getDoneTodayCount() = dao.getDoneTodayCount()
override fun getOverdueTasks(): Flow<List<Task>> = override fun getOverdueTasks(): Flow<List<Task>> =
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } } dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
@@ -8,6 +8,7 @@ interface TaskRepository {
fun getTasksBySection(sectionId: String): Flow<List<Task>> fun getTasksBySection(sectionId: String): Flow<List<Task>>
fun getInboxTasks(): Flow<List<Task>> fun getInboxTasks(): Flow<List<Task>>
fun getTodayTasks(): Flow<List<Task>> fun getTodayTasks(): Flow<List<Task>>
fun getDoneTodayCount(): Flow<Int>
fun getOverdueTasks(): Flow<List<Task>> fun getOverdueTasks(): Flow<List<Task>>
fun getSubTasks(parentId: String): Flow<List<Task>> fun getSubTasks(parentId: String): Flow<List<Task>>
suspend fun getTaskById(id: String): Task? suspend fun getTaskById(id: String): Task?
@@ -1,49 +1,29 @@
package com.planify.mobile.ui package com.planify.mobile.ui
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.collectAsState
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.material.icons.Icons 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.Add
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.outlined.Person
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.Today 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.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.NavigationBar
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel 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.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route import com.planify.mobile.ui.navigation.Route
import com.planify.mobile.ui.task.TaskEditSheet 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 @Composable
fun MainScreen( fun MainScreen(
authViewModel: AuthViewModel, authViewModel: AuthViewModel,
viewModel: DrawerViewModel = hiltViewModel(), drawerViewModel: DrawerViewModel = hiltViewModel(),
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val projects by viewModel.projects.collectAsState()
val navBackStack by navController.currentBackStackEntryAsState() val navBackStack by navController.currentBackStackEntryAsState()
val currentRoute = navBackStack?.destination?.route val currentRoute = navBackStack?.destination?.route
var showCreateTask by remember { mutableStateOf(false) } var showCreateTask by remember { mutableStateOf(false) }
var selectedTask by remember { mutableStateOf<Task?>(null) } var selectedTask by remember { mutableStateOf<Task?>(null) }
val projects by drawerViewModel.projects.collectAsState()
val inboxProjectId = projects.find { it.isInbox }?.id ?: "" 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) val createProjectId = if (currentRoute == Route.Project().path)
navBackStack?.arguments?.getString("projectId") ?: inboxProjectId navBackStack?.arguments?.getString("projectId") ?: inboxProjectId
else else
inboxProjectId inboxProjectId
val drawerTitles = mapOf( val hideBottomBarRoutes = setOf<String>()
Route.Inbox.path to "Inbox", val showBottomBar = currentRoute !in hideBottomBarRoutes
Route.Today.path to "Aujourd'hui", val hideFabRoutes = setOf(Route.Settings.path)
Route.Scheduled.path to "Planifié", val showFab = currentRoute !in hideFabRoutes
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"
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( Scaffold(
topBar = { bottomBar = {
TopAppBar( if (showBottomBar) {
title = { Text(title) }, NavigationBar(
navigationIcon = { containerColor = MaterialTheme.colorScheme.surface,
IconButton(onClick = { scope.launch { drawerState.open() } }) { tonalElevation = 0.dp,
Icon(Icons.Outlined.Menu, contentDescription = "Menu") ) {
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 = { floatingActionButton = {
if (currentRoute != Route.Settings.path) { if (showFab) {
FloatingActionButton( FloatingActionButton(
onClick = { showCreateTask = true }, onClick = { showCreateTask = true },
containerColor = MaterialTheme.colorScheme.primary, 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 -> ) { padding ->
PlanifyNavHost( PlanifyNavHost(
navController = navController, navController = navController,
@@ -243,4 +157,3 @@ fun MainScreen(
} }
} }
} }
}
@@ -1,18 +1,26 @@
package com.planify.mobile.ui.components package com.planify.mobile.ui.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi 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.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CheckboxDefaults 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -20,7 +28,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -35,40 +45,54 @@ fun TaskRow(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, 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( val textColor by animateColorAsState(
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) if (task.checked) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface, else MaterialTheme.colorScheme.onSurface,
label = "textColor", label = "textColor",
) )
Row( Surface(
onClick = onClick,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = {}) .padding(horizontal = 16.dp, vertical = 4.dp),
.padding(horizontal = 16.dp, vertical = 8.dp), shape = RoundedCornerShape(18.dp),
verticalAlignment = Alignment.CenterVertically, 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, checked = task.checked,
onCheckedChange = onCheckedChange, color = checkColor,
colors = CheckboxDefaults.colors( onClick = { onCheckedChange(!task.checked) },
checkedColor = priorityColor, modifier = Modifier.padding(top = 1.dp),
uncheckedColor = priorityColor,
),
) )
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = task.content, text = task.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = textColor, color = textColor,
textDecoration = if (task.checked) TextDecoration.LineThrough else null, textDecoration = if (task.checked) TextDecoration.LineThrough else null,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val hasMeta = task.dueDate != null || task.labels.isNotEmpty()
if (hasMeta) {
Spacer(Modifier.height(5.dp))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (task.dueDate != null) { 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) @Preview(showBackground = true)
@Composable @Composable
private fun TaskRowPreview() { private fun TaskRowPreview() {
Surface {
Column { Column {
TaskRow( TaskRow(
task = Task( task = Task(
@@ -102,7 +150,6 @@ private fun TaskRowPreview() {
onCheckedChange = {}, onCheckedChange = {},
onClick = {}, onClick = {},
) )
Spacer(Modifier.height(1.dp))
TaskRow( TaskRow(
task = Task( task = Task(
id = "2", id = "2",
@@ -116,4 +163,3 @@ private fun TaskRowPreview() {
) )
} }
} }
}
@@ -13,6 +13,7 @@ import com.planify.mobile.ui.filter.FilterScreen
import com.planify.mobile.ui.inbox.InboxScreen import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen import com.planify.mobile.ui.label.LabelScreen
import com.planify.mobile.ui.project.ProjectScreen 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.scheduled.ScheduledScreen
import com.planify.mobile.ui.search.SearchScreen import com.planify.mobile.ui.search.SearchScreen
import com.planify.mobile.ui.settings.SettingsScreen import com.planify.mobile.ui.settings.SettingsScreen
@@ -27,17 +28,41 @@ fun PlanifyNavHost(
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Route.Inbox.path, startDestination = Route.Today.path,
modifier = modifier, modifier = modifier,
) { ) {
composable(Route.Inbox.path) {
InboxScreen(onTaskClick = onTaskClick)
}
composable(Route.Today.path) { composable(Route.Today.path) {
TodayScreen(onTaskClick = onTaskClick) 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( composable(
route = Route.Project().path, route = Route.Project().path,
arguments = listOf(navArgument("projectId") { type = NavType.StringType }) arguments = listOf(navArgument("projectId") { type = NavType.StringType })
@@ -50,10 +75,6 @@ fun PlanifyNavHost(
) )
} }
composable(Route.Scheduled.path) {
ScheduledScreen(onTaskClick = onTaskClick)
}
composable(Route.Search.path) { composable(Route.Search.path) {
SearchScreen(onTaskClick = onTaskClick) SearchScreen(onTaskClick = onTaskClick)
} }
@@ -6,6 +6,7 @@ sealed class Route(val path: String) {
data object Scheduled : Route("scheduled") data object Scheduled : Route("scheduled")
data object Search : Route("search") data object Search : Route("search")
data object Filter : Route("filter") data object Filter : Route("filter")
data object ProjectsList : Route("projects")
data class Project(val projectId: String = "{projectId}") : data class Project(val projectId: String = "{projectId}") :
Route("project/{projectId}") { Route("project/{projectId}") {
fun buildRoute(id: String) = "project/$id" fun buildRoute(id: String) = "project/$id"
@@ -1,21 +1,33 @@
package com.planify.mobile.ui.project 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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState 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.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,7 +37,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle import com.planify.mobile.domain.model.ViewStyle
@@ -49,14 +65,27 @@ fun ProjectScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) } 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() }) { if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
EmptyState( EmptyState(
icon = Icons.Outlined.FolderOpen, icon = Icons.Outlined.FolderOpen,
title = "Projet vide", title = "Projet vide",
subtitle = "Créez votre première tâche avec le bouton +", subtitle = "Créez votre première tâche avec le bouton +",
modifier = modifier, modifier = Modifier.weight(1f),
) )
return return@Column
} }
when (state.viewStyle) { when (state.viewStyle) {
@@ -71,13 +100,139 @@ fun ProjectScreen(
onTaskClick = onTaskClick, onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) }, onCheckedChange = { task -> viewModel.toggleTask(task) },
onReorder = { viewModel.reorderTasks(it) }, onReorder = { viewModel.reorderTasks(it) },
modifier = modifier, modifier = Modifier.weight(1f),
) )
ViewStyle.BOARD -> ProjectBoardView( ViewStyle.BOARD -> ProjectBoardView(
state = state, state = state,
onTaskClick = onTaskClick, onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) }, 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 listState = rememberLazyListState()
val reorderState = rememberReorderState() val reorderState = rememberReorderState()
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) { LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 96.dp),
) {
state.sections.forEach { group -> state.sections.forEach { group ->
val key = group.section?.id ?: "unsectioned" val key = group.section?.id ?: "unsectioned"
val name = group.section?.name ?: "Sans section" 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 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -11,38 +22,54 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow 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 @Composable
fun ScheduledScreen( fun ScheduledScreen(
onTaskClick: (Task) -> Unit, onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier,
viewModel: ScheduledViewModel = hiltViewModel(), viewModel: ScheduledViewModel = hiltViewModel(),
) { ) {
val groups by viewModel.groups.collectAsState() 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()) { if (groups.isEmpty()) {
item {
EmptyState( EmptyState(
icon = Icons.Outlined.CalendarMonth, icon = Icons.Outlined.CalendarMonth,
title = "Aucune tâche planifiée", title = "Aucune tâche planifiée",
subtitle = "Les tâches avec une date d'échéance apparaîtront ici", subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
) )
return }
return@LazyColumn
} }
LazyColumn(modifier = Modifier.fillMaxSize()) {
groups.forEach { group -> groups.forEach { group ->
item(key = group.label) { item(key = "head_${group.label}") {
Text( DayGroupHeader(label = group.label, count = group.tasks.size)
text = group.label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
} }
items(group.tasks, key = { it.id }) { task -> items(group.tasks, key = { it.id }) { task ->
TaskRow( 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 androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
// Bonsai design tokens — extracted from the webapp CSS palette // Bonsai design tokens — warm cream + forest green 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)
private val Gray50 = Color(0xFFF9FAFB) // Light
private val Gray100 = Color(0xFFF3F4F6) private val Green_2F7A4F = Color(0xFF2F7A4F)
private val Gray200 = Color(0xFFE5E7EB) private val Green_1E5E3C = Color(0xFF1E5E3C)
private val Gray300 = Color(0xFFD1D5DB) private val Green_DCEAD8 = Color(0xFFDCEAD8)
private val Gray400 = Color(0xFF9CA3AF) private val Green_2C6A45 = Color(0xFF2C6A45)
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)
private val Green800 = Color(0xFF276749) private val Cream_F1ECE0 = Color(0xFFF1ECE0)
private val Green600 = Color(0xFF2F855A) private val Cream_FBF8F1 = Color(0xFFFBF8F1)
private val Green200 = Color(0xFFD1FAE5) private val Cream_EBE5D6 = Color(0xFFEBE5D6)
private val Green900 = Color(0xFF14532D)
private val Red600 = Color(0xFFDC2626) private val Ink_22291F = Color(0xFF22291F)
private val Red100 = Color(0xFFFEE2E2) private val Ink_6A7163 = Color(0xFF6A7163)
private val Red900 = Color(0xFF7F1D1D) 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( private val BonsaiLightColorScheme = lightColorScheme(
primary = Blue600, primary = Green_2F7A4F,
onPrimary = Color.White, onPrimary = Cream_FBF8F1,
primaryContainer = Blue100, primaryContainer = Green_DCEAD8,
onPrimaryContainer = Blue800, onPrimaryContainer = Green_2C6A45,
secondary = Blue700, secondary = Terra_C2683C,
onSecondary = Color.White, onSecondary = Color.White,
secondaryContainer = Blue50, secondaryContainer = Terra_F2DECE,
onSecondaryContainer = Blue700, onSecondaryContainer = Terra_9E5026,
tertiary = Green800, tertiary = Green_1E5E3C,
onTertiary = Color.White, onTertiary = Cream_FBF8F1,
tertiaryContainer = Green200, tertiaryContainer = Green_DCEAD8,
onTertiaryContainer = Green900, onTertiaryContainer = Green_2C6A45,
error = Red600, error = Terra_C2683C,
onError = Color.White, onError = Color.White,
errorContainer = Red100, errorContainer = Terra_F2DECE,
onErrorContainer = Red900, onErrorContainer = Terra_9E5026,
background = Gray50, background = Cream_F1ECE0,
onBackground = Gray900, onBackground = Ink_22291F,
surface = Color.White, surface = Cream_FBF8F1,
onSurface = Gray900, onSurface = Ink_22291F,
surfaceVariant = Gray100, surfaceVariant = Cream_EBE5D6,
onSurfaceVariant = Gray700, onSurfaceVariant = Ink_6A7163,
outline = Gray200, outline = Line_E4DCC9,
outlineVariant = Gray300, outlineVariant = Line_EFE9DB,
inverseSurface = Gray800, inverseSurface = Ink_22291F,
inverseOnSurface = Gray50, inverseOnSurface = Cream_FBF8F1,
inversePrimary = Blue300, inversePrimary = Green_DCEAD8,
) )
private val BonsaiDarkColorScheme = darkColorScheme( private val BonsaiDarkColorScheme = darkColorScheme(
primary = Blue300, primary = DGreen_74C58A,
onPrimary = Blue800, onPrimary = DGreen_08140C,
primaryContainer = Blue700, primaryContainer = DGreen_1F3422,
onPrimaryContainer = Blue100, onPrimaryContainer = DGreen_9BD9AC,
secondary = Blue400, secondary = DTerra_E0905E,
onSecondary = Blue800, onSecondary = DGreen_08140C,
secondaryContainer = Blue800, secondaryContainer = DTerra_33231A,
onSecondaryContainer = Blue100, onSecondaryContainer = DTerra_E9A579,
tertiary = Green200, tertiary = DGreen_4FA268,
onTertiary = Green900, onTertiary = DGreen_08140C,
tertiaryContainer = Green600, tertiaryContainer = DGreen_1F3422,
onTertiaryContainer = Green200, onTertiaryContainer = DGreen_9BD9AC,
error = Color(0xFFF87171), error = DTerra_E0905E,
onError = Red900, onError = DGreen_08140C,
errorContainer = Color(0xFF991B1B), errorContainer = DTerra_33231A,
onErrorContainer = Color(0xFFFECACA), onErrorContainer = DTerra_E9A579,
background = Gray900, background = DBg_10190F,
onBackground = Gray50, onBackground = DInk_EAF0E3,
surface = Gray800, surface = DSurf_18241A,
onSurface = Gray50, onSurface = DInk_EAF0E3,
surfaceVariant = Gray700, surfaceVariant = DSurf2_1F2E21,
onSurfaceVariant = Gray300, onSurfaceVariant = DInk_9DAE9C,
outline = Gray600, outline = DLine_27361F,
outlineVariant = Gray700, outlineVariant = DLine_1E2D1A,
inverseSurface = Gray50, inverseSurface = DInk_EAF0E3,
inverseOnSurface = Gray900, inverseOnSurface = DBg_10190F,
inversePrimary = Blue600, inversePrimary = Green_2F7A4F,
) )
@Composable @Composable
@@ -1,27 +1,54 @@
package com.planify.mobile.ui.today 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Today 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.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow import com.planify.mobile.ui.components.TaskRow
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable @Composable
fun TodayScreen( fun TodayScreen(
@@ -32,30 +59,37 @@ fun TodayScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) } 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) { if (state.totalCount == 0 && !state.isLoading) {
item {
EmptyState( EmptyState(
icon = Icons.Outlined.Today, icon = Icons.Outlined.Today,
title = "Rien pour aujourd'hui", title = "Rien pour aujourd'hui",
subtitle = "Profitez de votre journée !", subtitle = "Profitez de votre journée !",
modifier = modifier,
) )
return }
return@LazyColumn
} }
LazyColumn(modifier = modifier.fillMaxSize()) {
if (state.overdueTasks.isNotEmpty()) { if (state.overdueTasks.isNotEmpty()) {
item { item { SectionLabel(name = "En retard", count = state.overdueTasks.size) }
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) {
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task -> items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
TaskRow( TaskRow(
task = task, task = task,
@@ -64,22 +98,11 @@ fun TodayScreen(
) )
} }
} }
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
}
state.tasksByProject.forEach { (projectName, tasks) -> state.tasksByProject.forEach { (projectName, tasks) ->
item(key = "header_$projectName") { item(key = "header_$projectName") {
SectionHeader( SectionLabel(name = projectName, count = tasks.size)
name = projectName,
taskCount = tasks.size,
collapsed = projectName in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle(projectName)
},
onAddTask = {},
)
} }
if (projectName !in collapsedSections.value) {
items(tasks, key = { it.id }) { task -> items(tasks, key = { it.id }) { task ->
TaskRow( TaskRow(
task = task, task = task,
@@ -90,7 +113,170 @@ 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) = @Composable
if (contains(key)) minus(key) else plus(key) 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 tasksByProject: Map<String, List<Task>> = emptyMap(),
val overdueTasks: List<Task> = emptyList(), val overdueTasks: List<Task> = emptyList(),
val totalCount: Int = 0, val totalCount: Int = 0,
val doneCount: Int = 0,
val isLoading: Boolean = false, val isLoading: Boolean = false,
) )
@@ -28,15 +29,18 @@ class TodayViewModel @Inject constructor(
val uiState = combine( val uiState = combine(
taskRepository.getTodayTasks(), taskRepository.getTodayTasks(),
taskRepository.getOverdueTasks(), taskRepository.getOverdueTasks(),
taskRepository.getDoneTodayCount(),
projectRepository.getAllProjects(), projectRepository.getAllProjects(),
) { today, overdue, projects -> ) { today, overdue, done, projects ->
val projectMap = projects.associateBy { it.id } val projectMap = projects.associateBy { it.id }
val grouped = today val grouped = today
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId } .groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
TodayUiState( TodayUiState(
tasksByProject = grouped, tasksByProject = grouped,
overdueTasks = overdue, overdueTasks = overdue,
totalCount = today.size + overdue.size, totalCount = today.size + overdue.size + done,
doneCount = done,
isLoading = false,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,