feat: [#9] vue Projet (ProjectScreen liste/board, ProjectViewModel, SectionRepository)

This commit is contained in:
2026-06-06 06:06:05 +02:00
parent 65a54af66a
commit 3ab7a48384
5 changed files with 276 additions and 0 deletions
@@ -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,
)
}
@@ -1,8 +1,10 @@
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
@@ -19,4 +21,7 @@ abstract class RepositoryModule {
@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)
}
@@ -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) }
}
}