Merge pull request 'Milestone/lot 2 navigation' (#32) from milestone/lot-2-navigation into main

Reviewed-on: Gato/Planify-mobile#32
This commit is contained in:
2026-06-06 06:23:11 +02:00
22 changed files with 1344 additions and 1 deletions
@@ -0,0 +1,61 @@
package com.planify.mobile.data.repository
import com.planify.mobile.data.local.dao.ProjectDao
import com.planify.mobile.data.local.entity.ProjectEntity
import com.planify.mobile.domain.model.BackendType
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.model.SortBy
import com.planify.mobile.domain.model.ViewStyle
import com.planify.mobile.domain.repository.ProjectRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class ProjectRepositoryImpl @Inject constructor(
private val dao: ProjectDao,
) : ProjectRepository {
override fun getAllProjects(): Flow<List<Project>> =
dao.getAllProjects().map { list -> list.map { it.toDomain() } }
override fun getFavoriteProjects(): Flow<List<Project>> =
dao.getFavoriteProjects().map { list -> list.map { it.toDomain() } }
override fun getSubProjects(parentId: String): Flow<List<Project>> =
dao.getSubProjects(parentId).map { list -> list.map { it.toDomain() } }
override suspend fun getProjectById(id: String): Project? =
dao.getById(id)?.toDomain()
override suspend fun getInboxProject(): Project? =
dao.getInboxProject()?.toDomain()
override suspend fun insertProject(project: Project) =
dao.insert(project.toEntity())
override suspend fun updateProject(project: Project) =
dao.update(project.toEntity())
override suspend fun deleteProject(id: String) =
dao.softDelete(id)
private fun ProjectEntity.toDomain() = Project(
id = id, name = name, color = color, emoji = emoji,
parentId = parentId, sourceId = sourceId,
backendType = runCatching { BackendType.valueOf(backendType) }.getOrDefault(BackendType.LOCAL),
isInbox = isInbox, isFavorite = isFavorite, isArchived = isArchived, isDeleted = isDeleted,
viewStyle = runCatching { ViewStyle.valueOf(viewStyle) }.getOrDefault(ViewStyle.LIST),
sortedBy = runCatching { SortBy.valueOf(sortedBy) }.getOrDefault(SortBy.MANUAL),
sortAscending = sortAscending, childOrder = childOrder,
calendarUrl = calendarUrl, syncId = syncId,
)
private fun Project.toEntity() = ProjectEntity(
id = id, name = name, color = color, emoji = emoji,
parentId = parentId, sourceId = sourceId, backendType = backendType.name,
isInbox = isInbox, isFavorite = isFavorite, isArchived = isArchived, isDeleted = isDeleted,
viewStyle = viewStyle.name, sortedBy = sortedBy.name,
sortAscending = sortAscending, childOrder = childOrder,
calendarUrl = calendarUrl, syncId = syncId,
)
}
@@ -0,0 +1,36 @@
package com.planify.mobile.data.repository
import com.planify.mobile.data.local.dao.SectionDao
import com.planify.mobile.data.local.entity.SectionEntity
import com.planify.mobile.domain.model.Section
import com.planify.mobile.domain.repository.SectionRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class SectionRepositoryImpl @Inject constructor(
private val dao: SectionDao,
) : SectionRepository {
override fun getSectionsByProject(projectId: String): Flow<List<Section>> =
dao.getSectionsByProject(projectId).map { it.map { e -> e.toDomain() } }
override suspend fun getSectionById(id: String): Section? =
dao.getById(id)?.toDomain()
override suspend fun insertSection(section: Section) = dao.insert(section.toEntity())
override suspend fun updateSection(section: Section) = dao.update(section.toEntity())
override suspend fun deleteSection(id: String) = dao.softDelete(id)
private fun SectionEntity.toDomain() = Section(
id = id, name = name, projectId = projectId, order = order,
isArchived = isArchived, isDeleted = isDeleted, collapsed = collapsed,
icalUrl = icalUrl, etag = etag,
)
private fun Section.toEntity() = SectionEntity(
id = id, name = name, projectId = projectId, order = order,
isArchived = isArchived, isDeleted = isDeleted, collapsed = collapsed,
icalUrl = icalUrl, etag = etag,
)
}
@@ -0,0 +1,108 @@
package com.planify.mobile.data.repository
import com.planify.mobile.data.local.dao.TaskDao
import com.planify.mobile.data.local.entity.TaskEntity
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.ItemType
import com.planify.mobile.domain.model.RecurrencyType
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.json.Json
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import javax.inject.Inject
class TaskRepositoryImpl @Inject constructor(
private val dao: TaskDao,
) : TaskRepository {
override fun getTasksByProject(projectId: String): Flow<List<Task>> =
dao.getTasksByProject(projectId).map { it.map { e -> e.toDomain() } }
override fun getTasksBySection(sectionId: String): Flow<List<Task>> =
dao.getTasksBySection(sectionId).map { it.map { e -> e.toDomain() } }
override fun getInboxTasks(): Flow<List<Task>> =
dao.getInboxTasks().map { it.map { e -> e.toDomain() } }
override fun getTodayTasks(): Flow<List<Task>> =
dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
override fun getOverdueTasks(): Flow<List<Task>> =
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
override fun getSubTasks(parentId: String): Flow<List<Task>> =
dao.getSubTasks(parentId).map { it.map { e -> e.toDomain() } }
override suspend fun getTaskById(id: String): Task? =
dao.getTaskById(id)?.toDomain()
override suspend fun insertTask(task: Task) =
dao.insert(task.toEntity())
override suspend fun updateTask(task: Task) =
dao.update(task.toEntity())
override suspend fun deleteTask(id: String) {
val now = DateTimeFormatter.ISO_INSTANT.format(Instant.now().atOffset(ZoneOffset.UTC))
dao.softDelete(id, now)
}
override suspend fun checkTask(id: String, checked: Boolean) {
val now = DateTimeFormatter.ISO_INSTANT.format(Instant.now().atOffset(ZoneOffset.UTC))
dao.setChecked(id, checked, if (checked) now else null, now)
}
private fun TaskEntity.toDomain() = Task(
id = id,
content = content,
description = description,
projectId = projectId,
sectionId = sectionId,
parentId = parentId,
priority = priority,
checked = checked,
dueDate = dueDate?.let { runCatching { Json.decodeFromString<DueDate>(it) }.getOrNull() },
deadlineDate = deadlineDate,
labels = runCatching { Json.decodeFromString<List<String>>(labels) }.getOrDefault(emptyList()),
pinned = pinned,
collapsed = collapsed,
childOrder = childOrder,
addedAt = addedAt,
updatedAt = updatedAt,
completedAt = completedAt,
itemType = runCatching { ItemType.valueOf(itemType) }.getOrDefault(ItemType.TASK),
calendarEventUid = calendarEventUid,
icalUrl = icalUrl,
etag = etag,
responsibleUid = responsibleUid,
)
private fun Task.toEntity() = TaskEntity(
id = id,
content = content,
description = description,
projectId = projectId,
sectionId = sectionId,
parentId = parentId,
priority = priority,
checked = checked,
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
deadlineDate = deadlineDate,
labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.builtins.serializer()), labels),
pinned = pinned,
collapsed = collapsed,
childOrder = childOrder,
addedAt = addedAt,
updatedAt = updatedAt,
completedAt = completedAt,
itemType = itemType.name,
calendarEventUid = calendarEventUid,
icalUrl = icalUrl,
etag = etag,
responsibleUid = responsibleUid,
)
}
@@ -0,0 +1,27 @@
package com.planify.mobile.di
import com.planify.mobile.data.repository.ProjectRepositoryImpl
import com.planify.mobile.data.repository.SectionRepositoryImpl
import com.planify.mobile.data.repository.TaskRepositoryImpl
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SectionRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindProjectRepository(impl: ProjectRepositoryImpl): ProjectRepository
@Binds @Singleton
abstract fun bindTaskRepository(impl: TaskRepositoryImpl): TaskRepository
@Binds @Singleton
abstract fun bindSectionRepository(impl: SectionRepositoryImpl): SectionRepository
}
@@ -0,0 +1,12 @@
package com.planify.mobile.domain.repository
import com.planify.mobile.domain.model.Section
import kotlinx.coroutines.flow.Flow
interface SectionRepository {
fun getSectionsByProject(projectId: String): Flow<List<Section>>
suspend fun getSectionById(id: String): Section?
suspend fun insertSection(section: Section)
suspend fun updateSection(section: Section)
suspend fun deleteSection(id: String)
}
@@ -15,7 +15,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PlanifyTheme { PlanifyTheme {
// TODO #6 : PlanifyNavHost() MainScreen()
} }
} }
} }
@@ -0,0 +1,141 @@
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
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.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.planify.mobile.ui.navigation.DrawerViewModel
import com.planify.mobile.ui.navigation.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: 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
val drawerTitles = mapOf(
Route.Inbox.path to "Inbox",
Route.Today.path to "Aujourd'hui",
Route.Scheduled.path to "Planifié",
)
val title = drawerTitles[currentRoute]
?: projects.find { "project/${it.id}" == currentRoute }?.name
?: "Planify"
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
Text(
text = "Planify",
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() }
},
)
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 == "project/${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 = false,
onClick = { scope.launch { drawerState.close() } },
)
Spacer(Modifier.height(8.dp))
}
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Outlined.Menu, contentDescription = "Menu")
}
},
)
},
) { padding ->
PlanifyNavHost(
navController = navController,
modifier = Modifier.padding(padding),
)
}
}
}
@@ -0,0 +1,50 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@Composable
fun DueDateChip(dateIso: String, modifier: Modifier = Modifier) {
val date = runCatching { LocalDate.parse(dateIso.take(10)) }.getOrNull() ?: return
val today = LocalDate.now()
val overdue = date.isBefore(today)
val color = if (overdue) Color(0xFFE53935) else MaterialTheme.colorScheme.onSurfaceVariant
val label = when (date) {
today -> "Aujourd'hui"
today.plusDays(1) -> "Demain"
else -> date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT))
}
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.CalendarToday,
contentDescription = null,
tint = color,
modifier = Modifier.width(14.dp)
)
Spacer(Modifier.width(2.dp))
Text(text = label, style = MaterialTheme.typography.labelSmall, color = color)
}
}
@Preview
@Composable
private fun DueDateChipPreview() {
DueDateChip(dateIso = LocalDate.now().toString())
}
@@ -0,0 +1,67 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun EmptyState(
icon: ImageVector,
title: String,
subtitle: String? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
)
Spacer(Modifier.height(16.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (subtitle != null) {
Spacer(Modifier.height(8.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = TextAlign.Center,
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun EmptyStatePreview() {
EmptyState(
icon = Icons.Outlined.CheckCircle,
title = "Aucune tâche",
subtitle = "Créez votre première tâche avec le bouton +",
)
}
@@ -0,0 +1,30 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun LabelChip(name: String, color: Color, modifier: Modifier = Modifier) {
Text(
text = name,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = modifier
.background(color.copy(alpha = 0.12f), RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
@Preview
@Composable
private fun LabelChipPreview() {
LabelChip(name = "android", color = Color(0xFF1E88E5))
}
@@ -0,0 +1,34 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
val priorityColor = mapOf(
1 to Color(0xFFE53935),
2 to Color(0xFFFB8C00),
3 to Color(0xFF1E88E5),
4 to Color(0xFF9E9E9E),
)
@Composable
fun PriorityBadge(priority: Int, modifier: Modifier = Modifier) {
val color = priorityColor[priority] ?: priorityColor[4]!!
Box(
modifier = modifier
.size(12.dp)
.background(color, CircleShape)
)
}
@Preview
@Composable
private fun PriorityBadgePreview() {
PriorityBadge(priority = 1)
}
@@ -0,0 +1,80 @@
package com.planify.mobile.ui.components
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun SectionHeader(
name: String,
taskCount: Int,
collapsed: Boolean,
onToggleCollapse: () -> Unit,
onAddTask: () -> Unit,
modifier: Modifier = Modifier,
) {
val rotation by animateFloatAsState(if (collapsed) -90f else 0f, label = "collapse")
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onToggleCollapse() }
.padding(start = 8.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = if (collapsed) "Déplier" else "Replier",
modifier = Modifier.rotate(rotation),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
Text(
text = name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
if (taskCount > 0) {
Text(
text = "$taskCount",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onAddTask) {
Icon(Icons.Default.Add, contentDescription = "Ajouter une tâche")
}
}
}
@Preview(showBackground = true)
@Composable
private fun SectionHeaderPreview() {
SectionHeader(
name = "En cours",
taskCount = 3,
collapsed = false,
onToggleCollapse = {},
onAddTask = {},
)
}
@@ -0,0 +1,119 @@
package com.planify.mobile.ui.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.planify.mobile.domain.model.Task
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TaskRow(
task: Task,
onCheckedChange: (Boolean) -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val priorityColor = priorityColor[task.priority] ?: Color.Gray
val textColor by animateColorAsState(
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
else MaterialTheme.colorScheme.onSurface,
label = "textColor",
)
Row(
modifier = modifier
.fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = {})
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = task.checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(
checkedColor = priorityColor,
uncheckedColor = priorityColor,
),
)
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.content,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (task.dueDate != null) {
DueDateChip(dateIso = task.dueDate.date)
}
task.labels.take(2).forEach { labelName ->
LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary)
}
}
}
if (task.priority < 4) {
Spacer(Modifier.width(8.dp))
PriorityBadge(priority = task.priority)
}
}
}
@Preview(showBackground = true)
@Composable
private fun TaskRowPreview() {
Surface {
Column {
TaskRow(
task = Task(
id = "1",
content = "Implémenter la navigation principale",
projectId = "p1",
priority = 2,
labels = listOf("android", "ui"),
),
onCheckedChange = {},
onClick = {},
)
Spacer(Modifier.height(1.dp))
TaskRow(
task = Task(
id = "2",
content = "Tâche terminée",
projectId = "p1",
priority = 4,
checked = true,
),
onCheckedChange = {},
onClick = {},
)
}
}
}
@@ -0,0 +1,75 @@
package com.planify.mobile.ui.inbox
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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.unit.dp
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
@Composable
fun InboxScreen(
onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier,
viewModel: InboxViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
when {
state.isLoading -> Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.tasks.isEmpty() && state.overdueTasks.isEmpty() ->
EmptyState(
icon = Icons.Outlined.Inbox,
title = "Inbox vide",
subtitle = "Créez une tâche avec le bouton +",
modifier = modifier,
)
else -> LazyColumn(modifier = modifier.fillMaxSize()) {
if (state.overdueTasks.isNotEmpty()) {
item {
Text(
text = "En retard",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
items(state.overdueTasks, key = { it.id }) { task ->
TaskRow(
task = task,
onCheckedChange = { viewModel.toggleTask(task) },
onClick = { onTaskClick(task) },
)
}
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
}
items(state.tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onCheckedChange = { viewModel.toggleTask(task) },
onClick = { onTaskClick(task) },
)
}
}
}
}
@@ -0,0 +1,48 @@
package com.planify.mobile.ui.inbox
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
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 kotlinx.coroutines.launch
import javax.inject.Inject
data class InboxUiState(
val tasks: List<Task> = emptyList(),
val overdueTasks: List<Task> = emptyList(),
val showCompleted: Boolean = false,
val isLoading: Boolean = false,
)
@HiltViewModel
class InboxViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
val uiState = combine(
taskRepository.getInboxTasks(),
taskRepository.getOverdueTasks(),
) { tasks, overdue ->
InboxUiState(tasks = tasks, overdueTasks = overdue)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InboxUiState(isLoading = true),
)
fun toggleTask(task: Task) {
viewModelScope.launch {
taskRepository.checkTask(task.id, !task.checked)
}
}
fun deleteTask(task: Task) {
viewModelScope.launch {
taskRepository.deleteTask(task.id)
}
}
}
@@ -0,0 +1,22 @@
package com.planify.mobile.ui.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.repository.ProjectRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class DrawerViewModel @Inject constructor(
projectRepository: ProjectRepository,
) : ViewModel() {
val projects = projectRepository.getAllProjects()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val favorites = projectRepository.getFavoriteProjects()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList<Project>())
}
@@ -0,0 +1,48 @@
package com.planify.mobile.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.project.ProjectScreen
import com.planify.mobile.ui.today.TodayScreen
@Composable
fun PlanifyNavHost(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
NavHost(
navController = navController,
startDestination = Route.Inbox.path,
modifier = modifier,
) {
composable(Route.Inbox.path) {
InboxScreen(
onTaskClick = { /* TODO #11 : ouvrir édition */ }
)
}
composable(Route.Today.path) {
TodayScreen(
onTaskClick = { /* TODO #11 : ouvrir édition */ }
)
}
composable(
route = Route.Project().path,
arguments = listOf(navArgument("projectId") { type = NavType.StringType })
) { backStack ->
val projectId = backStack.arguments?.getString("projectId") ?: return@composable
ProjectScreen(
projectId = projectId,
onTaskClick = { /* TODO #11 : ouvrir édition */ },
onBack = { navController.popBackStack() },
)
}
}
}
@@ -0,0 +1,16 @@
package com.planify.mobile.ui.navigation
sealed class Route(val path: String) {
data object Inbox : Route("inbox")
data object Today : Route("today")
data object Scheduled : Route("scheduled")
data class Project(val projectId: String = "{projectId}") :
Route("project/{projectId}") {
fun buildRoute(id: String) = "project/$id"
}
data class Label(val labelId: String = "{labelId}") :
Route("label/{labelId}") {
fun buildRoute(id: String) = "label/$id"
}
data object Settings : Route("settings")
}
@@ -0,0 +1,152 @@
package com.planify.mobile.ui.project
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow
@Composable
fun ProjectScreen(
projectId: String,
onTaskClick: (Task) -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ProjectViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
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,
)
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
}
},
onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) },
modifier = modifier,
)
ViewStyle.BOARD -> ProjectBoardView(
state = state,
onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) },
modifier = modifier,
)
}
}
@Composable
private fun ProjectListView(
state: ProjectUiState,
collapsedSections: Set<String>,
onToggleSection: (String) -> Unit,
onTaskClick: (Task) -> Unit,
onCheckedChange: (Task) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
state.sections.forEach { group ->
val key = group.section?.id ?: "unsectioned"
val name = group.section?.name ?: "Sans section"
item(key = "header_$key") {
SectionHeader(
name = name,
taskCount = group.tasks.size,
collapsed = key in collapsedSections,
onToggleCollapse = { onToggleSection(key) },
onAddTask = {},
)
}
if (key !in collapsedSections) {
items(group.tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onCheckedChange = { onCheckedChange(task) },
onClick = { onTaskClick(task) },
)
}
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
}
}
}
}
@Composable
private fun ProjectBoardView(
state: ProjectUiState,
onTaskClick: (Task) -> Unit,
onCheckedChange: (Task) -> Unit,
modifier: Modifier = Modifier,
) {
LazyRow(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 12.dp),
) {
items(state.sections) { group ->
Card(
modifier = Modifier
.fillParentMaxHeight()
.padding(horizontal = 4.dp)
.fillMaxWidth(0.75f),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column {
Text(
text = group.section?.name ?: "Sans section",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(12.dp),
)
LazyColumn {
items(group.tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onCheckedChange = { onCheckedChange(task) },
onClick = { onTaskClick(task) },
)
}
}
}
}
}
}
}
@@ -0,0 +1,71 @@
package com.planify.mobile.ui.project
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.model.Section
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SectionRepository
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 kotlinx.coroutines.launch
import javax.inject.Inject
data class SectionWithTasks(val section: Section?, val tasks: List<Task>)
data class ProjectUiState(
val project: Project? = null,
val sections: List<SectionWithTasks> = emptyList(),
val viewStyle: ViewStyle = ViewStyle.LIST,
val isLoading: Boolean = true,
)
@HiltViewModel
class ProjectViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val taskRepository: TaskRepository,
private val projectRepository: ProjectRepository,
private val sectionRepository: SectionRepository,
) : ViewModel() {
private val projectId: String = checkNotNull(savedStateHandle["projectId"])
val uiState = combine(
projectRepository.getAllProjects(),
sectionRepository.getSectionsByProject(projectId),
taskRepository.getTasksByProject(projectId),
) { projects, sections, tasks ->
val project = projects.find { it.id == projectId }
val unsectioned = tasks.filter { it.sectionId == null }
val sectionedGroups = sections.map { section ->
SectionWithTasks(
section = section,
tasks = tasks.filter { it.sectionId == section.id },
)
}
val allGroups = buildList {
if (unsectioned.isNotEmpty()) add(SectionWithTasks(null, unsectioned))
addAll(sectionedGroups)
}
ProjectUiState(
project = project,
sections = allGroups,
viewStyle = project?.viewStyle ?: ViewStyle.LIST,
isLoading = false,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProjectUiState(),
)
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}
@@ -0,0 +1,96 @@
package com.planify.mobile.ui.today
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.unit.dp
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
@Composable
fun TodayScreen(
onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier,
viewModel: TodayViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
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()) {
if (state.overdueTasks.isNotEmpty()) {
item {
SectionHeader(
name = "En retard",
taskCount = state.overdueTasks.size,
collapsed = "overdue" in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle("overdue")
},
onAddTask = {},
)
}
if ("overdue" !in collapsedSections.value) {
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 = {},
)
}
if (projectName !in collapsedSections.value) {
items(tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onCheckedChange = { viewModel.toggleTask(task) },
onClick = { onTaskClick(task) },
)
}
}
}
}
}
private fun Set<String>.toggle(key: String) =
if (contains(key)) minus(key) else plus(key)
@@ -0,0 +1,50 @@
package com.planify.mobile.ui.today
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
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 kotlinx.coroutines.launch
import javax.inject.Inject
data class TodayUiState(
val tasksByProject: Map<String, List<Task>> = emptyMap(),
val overdueTasks: List<Task> = emptyList(),
val totalCount: Int = 0,
val isLoading: Boolean = false,
)
@HiltViewModel
class TodayViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val projectRepository: ProjectRepository,
) : ViewModel() {
val uiState = combine(
taskRepository.getTodayTasks(),
taskRepository.getOverdueTasks(),
projectRepository.getAllProjects(),
) { today, overdue, 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,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TodayUiState(isLoading = true),
)
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}