From 27a3e569afccd439793553e0ef0a873b601b8210 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 14:13:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20full=20Bonsai=20design=20?= =?UTF-8?q?=E2=80=94=20bottom=20nav,=20green=20theme,=20redesigned=20scree?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/build.gradle.kts | 2 +- .../planify/mobile/data/local/dao/TaskDao.kt | 3 + .../data/repository/TaskRepositoryImpl.kt | 2 + .../domain/repository/TaskRepository.kt | 1 + .../java/com/planify/mobile/ui/MainScreen.kt | 289 +++++-------- .../planify/mobile/ui/components/TaskRow.kt | 174 +++++--- .../mobile/ui/navigation/PlanifyNavHost.kt | 39 +- .../com/planify/mobile/ui/navigation/Route.kt | 1 + .../mobile/ui/project/ProjectScreen.kt | 211 ++++++++-- .../mobile/ui/project/ProjectsListScreen.kt | 378 ++++++++++++++++++ .../ui/project/ProjectsListViewModel.kt | 63 +++ .../mobile/ui/scheduled/ScheduledScreen.kt | 189 ++++++++- .../java/com/planify/mobile/ui/theme/Theme.kt | 166 ++++---- .../planify/mobile/ui/today/TodayScreen.kt | 286 ++++++++++--- .../planify/mobile/ui/today/TodayViewModel.kt | 8 +- 15 files changed, 1379 insertions(+), 433 deletions(-) create mode 100644 app/src/main/java/com/planify/mobile/ui/project/ProjectsListScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/project/ProjectsListViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cacc0ff..db0c6bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "0.0.17" + versionName = "0.0.18" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt index 98e8c09..e17afd0 100644 --- a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt +++ b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt @@ -32,6 +32,9 @@ interface TaskDao { """) fun getTodayTasks(): Flow> + @Query("SELECT COUNT(*) FROM tasks WHERE date(due_date) = date('now') AND checked = 1 AND is_deleted = 0") + fun getDoneTodayCount(): Flow + @Query(""" SELECT * FROM tasks WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0 diff --git a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt index c6cee91..cac4a41 100644 --- a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt +++ b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt @@ -31,6 +31,8 @@ class TaskRepositoryImpl @Inject constructor( override fun getTodayTasks(): Flow> = dao.getTodayTasks().map { it.map { e -> e.toDomain() } } + override fun getDoneTodayCount() = dao.getDoneTodayCount() + override fun getOverdueTasks(): Flow> = dao.getOverdueTasks().map { it.map { e -> e.toDomain() } } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt index 7f811cd..e9df6c0 100644 --- a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt +++ b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt @@ -8,6 +8,7 @@ interface TaskRepository { fun getTasksBySection(sectionId: String): Flow> fun getInboxTasks(): Flow> fun getTodayTasks(): Flow> + fun getDoneTodayCount(): Flow fun getOverdueTasks(): Flow> fun getSubTasks(parentId: String): Flow> suspend fun getTaskById(id: String): Task? diff --git a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt index cf5665f..774300e 100644 --- a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt @@ -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,192 +35,125 @@ 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(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() + 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, + Scaffold( + 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 = { - navController.navigate(Route.Project().buildRoute(project.id)) - scope.launch { drawerState.close() } + 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, + ), ) } } - 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") - } - }, - ) - }, - floatingActionButton = { - if (currentRoute != Route.Settings.path) { - FloatingActionButton( - onClick = { showCreateTask = true }, - containerColor = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ) { - Icon(Icons.Outlined.Add, contentDescription = "Nouvelle tâche", tint = MaterialTheme.colorScheme.onPrimary) - } + }, + floatingActionButton = { + if (showFab) { + FloatingActionButton( + onClick = { showCreateTask = true }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = RoundedCornerShape(20.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = "Nouvelle tâche") } - }, - ) { padding -> - PlanifyNavHost( - navController = navController, - authViewModel = authViewModel, - onTaskClick = { task -> selectedTask = task }, - modifier = Modifier.padding(padding), + } + }, + containerColor = MaterialTheme.colorScheme.background, + ) { padding -> + PlanifyNavHost( + navController = navController, + authViewModel = authViewModel, + onTaskClick = { task -> selectedTask = task }, + modifier = Modifier.padding(padding), + ) + + if (showCreateTask) { + TaskEditSheet( + projectId = createProjectId, + onDismiss = { showCreateTask = false }, ) + } - if (showCreateTask) { - TaskEditSheet( - projectId = createProjectId, - onDismiss = { showCreateTask = false }, - ) - } - - selectedTask?.let { task -> - TaskEditSheet( - taskId = task.id, - projectId = task.projectId, - onDismiss = { selectedTask = null }, - ) - } + selectedTask?.let { task -> + TaskEditSheet( + taskId = task.id, + projectId = task.projectId, + onDismiss = { selectedTask = null }, + ) } } } diff --git a/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt b/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt index 83c9750..a39f211 100644 --- a/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt +++ b/app/src/main/java/com/planify/mobile/ui/components/TaskRow.kt @@ -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,53 +45,92 @@ 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( - checked = task.checked, - onCheckedChange = onCheckedChange, - colors = CheckboxDefaults.colors( - checkedColor = priorityColor, - uncheckedColor = priorityColor, - ), - ) - Spacer(Modifier.width(4.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = task.content, - style = MaterialTheme.typography.bodyMedium, - color = textColor, - textDecoration = if (task.checked) TextDecoration.LineThrough else null, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + CircleCheckbox( + checked = task.checked, + color = checkColor, + onClick = { onCheckedChange(!task.checked) }, + modifier = Modifier.padding(top = 1.dp), ) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (task.dueDate != null) { - DueDateChip(dateIso = task.dueDate.date) - } - task.labels.take(2).forEach { labelName -> - LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary) + Column(modifier = Modifier.weight(1f)) { + Text( + text = task.content, + 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(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (task.dueDate != null) { + DueDateChip(dateIso = task.dueDate.date) + } + task.labels.take(2).forEach { labelName -> + LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary) + } + } } } } - if (task.priority < 4) { - Spacer(Modifier.width(8.dp)) - PriorityBadge(priority = task.priority) + } +} + +@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,31 +138,28 @@ fun TaskRow( @Preview(showBackground = true) @Composable private fun TaskRowPreview() { - Surface { - Column { - TaskRow( - task = Task( - id = "1", - content = "Implémenter la navigation principale", - projectId = "p1", - priority = 2, - labels = listOf("android", "ui"), - ), - onCheckedChange = {}, - onClick = {}, - ) - Spacer(Modifier.height(1.dp)) - TaskRow( - task = Task( - id = "2", - content = "Tâche terminée", - projectId = "p1", - priority = 4, - checked = true, - ), - onCheckedChange = {}, - onClick = {}, - ) - } + Column { + TaskRow( + task = Task( + id = "1", + content = "Implémenter la navigation principale", + projectId = "p1", + priority = 2, + labels = listOf("android", "ui"), + ), + onCheckedChange = {}, + onClick = {}, + ) + TaskRow( + task = Task( + id = "2", + content = "Tâche terminée", + projectId = "p1", + priority = 4, + checked = true, + ), + onCheckedChange = {}, + onClick = {}, + ) } } diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt index f87e62f..d63ff58 100644 --- a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt +++ b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt @@ -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) } diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt b/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt index 3046dd0..aecd169 100644 --- a/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt +++ b/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt @@ -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" diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt index 615b7d1..069a000 100644 --- a/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt @@ -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,35 +65,174 @@ fun ProjectScreen( val state by viewModel.uiState.collectAsState() val collapsedSections = remember { mutableStateOf(setOf()) } - 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, + 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, ) - return - } - when (state.viewStyle) { - ViewStyle.LIST -> ProjectListView( - state = state, - collapsedSections = collapsedSections.value, - onToggleSection = { key -> - collapsedSections.value = collapsedSections.value.let { - if (it.contains(key)) it - key else it + key + 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.weight(1f), + ) + return@Column + } + + when (state.viewStyle) { + ViewStyle.LIST -> ProjectListView( + state = state, + collapsedSections = collapsedSections.value, + onToggleSection = { key -> + collapsedSections.value = collapsedSections.value.let { + if (it.contains(key)) it - key else it + key + } + }, + onTaskClick = onTaskClick, + onCheckedChange = { task -> viewModel.toggleTask(task) }, + onReorder = { viewModel.reorderTasks(it) }, + modifier = Modifier.weight(1f), + ) + ViewStyle.BOARD -> ProjectBoardView( + state = state, + onTaskClick = onTaskClick, + onCheckedChange = { task -> viewModel.toggleTask(task) }, + 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), + ) + } } - }, - onTaskClick = onTaskClick, - onCheckedChange = { task -> viewModel.toggleTask(task) }, - onReorder = { viewModel.reorderTasks(it) }, - modifier = modifier, + 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, ) - ViewStyle.BOARD -> ProjectBoardView( - state = state, - onTaskClick = onTaskClick, - onCheckedChange = { task -> viewModel.toggleTask(task) }, - modifier = modifier, + 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" diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectsListScreen.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectsListScreen.kt new file mode 100644 index 0000000..98e659a --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectsListScreen.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectsListViewModel.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectsListViewModel.kt new file mode 100644 index 0000000..e7c8cff --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectsListViewModel.kt @@ -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 = 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(), + ) +} diff --git a/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt b/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt index a6a083c..5e23e78 100644 --- a/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt @@ -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,39 +22,55 @@ 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() - if (groups.isEmpty()) { - 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 - } + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + // Header + item { ScheduledHeader() } - 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), + // 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@LazyColumn + } + + groups.forEach { group -> + item(key = "head_${group.label}") { + DayGroupHeader(label = group.label, count = group.tasks.size) + } items(group.tasks, key = { it.id }) { task -> TaskRow( task = task, @@ -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, + ) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt b/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt index 072cf51..3c5a8a0 100644 --- a/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt +++ b/app/src/main/java/com/planify/mobile/ui/theme/Theme.kt @@ -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 diff --git a/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt b/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt index dbe90d3..32b37be 100644 --- a/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/today/TodayScreen.kt @@ -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,65 +59,224 @@ fun TodayScreen( val state by viewModel.uiState.collectAsState() val collapsedSections = remember { mutableStateOf(setOf()) } - if (state.totalCount == 0 && !state.isLoading) { - EmptyState( - icon = Icons.Outlined.Today, - title = "Rien pour aujourd'hui", - subtitle = "Profitez de votre journée !", - modifier = modifier, - ) - return - } + LazyColumn( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + item { TodayHeader() } - LazyColumn(modifier = modifier.fillMaxSize()) { - if (state.overdueTasks.isNotEmpty()) { + if (state.totalCount > 0) { item { - SectionHeader( - name = "En retard", - taskCount = state.overdueTasks.size, - collapsed = "overdue" in collapsedSections.value, - onToggleCollapse = { - collapsedSections.value = collapsedSections.value.toggle("overdue") - }, - onAddTask = {}, + HeroCard( + doneCount = state.doneCount, + totalCount = state.totalCount, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp), ) } - if ("overdue" !in collapsedSections.value) { - items(state.overdueTasks, key = { "overdue_${it.id}" }) { task -> - TaskRow( - task = task, - onCheckedChange = { viewModel.toggleTask(task) }, - onClick = { onTaskClick(task) }, - ) - } + } + + if (state.totalCount == 0 && !state.isLoading) { + item { + EmptyState( + icon = Icons.Outlined.Today, + title = "Rien pour aujourd'hui", + subtitle = "Profitez de votre journée !", + ) + } + return@LazyColumn + } + + if (state.overdueTasks.isNotEmpty()) { + item { SectionLabel(name = "En retard", count = state.overdueTasks.size) } + items(state.overdueTasks, key = { "overdue_${it.id}" }) { task -> + TaskRow( + task = task, + onCheckedChange = { viewModel.toggleTask(task) }, + onClick = { onTaskClick(task) }, + ) } - 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, - onCheckedChange = { viewModel.toggleTask(task) }, - onClick = { onTaskClick(task) }, - ) - } + items(tasks, key = { it.id }) { task -> + TaskRow( + task = task, + onCheckedChange = { viewModel.toggleTask(task) }, + onClick = { onTaskClick(task) }, + ) } } } } -private fun Set.toggle(key: String) = - if (contains(key)) minus(key) else plus(key) +@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), + ) + } + } +} + +@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, + ) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt b/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt index a3f105e..7a74ab2 100644 --- a/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/today/TodayViewModel.kt @@ -16,6 +16,7 @@ data class TodayUiState( val tasksByProject: Map> = emptyMap(), val overdueTasks: List = 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,