From 3ab7a483846e237ea6a15158e489918b6940d6e7 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:06:05 +0200 Subject: [PATCH] feat: [#9] vue Projet (ProjectScreen liste/board, ProjectViewModel, SectionRepository) --- .../data/repository/SectionRepositoryImpl.kt | 36 +++++ .../com/planify/mobile/di/RepositoryModule.kt | 5 + .../domain/repository/SectionRepository.kt | 12 ++ .../mobile/ui/project/ProjectScreen.kt | 152 ++++++++++++++++++ .../mobile/ui/project/ProjectViewModel.kt | 71 ++++++++ 5 files changed, 276 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt create mode 100644 app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt new file mode 100644 index 0000000..1466d34 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/SectionRepositoryImpl.kt @@ -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> = + 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, + ) +} diff --git a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt index 56d0f50..0eb6edd 100644 --- a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt +++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt new file mode 100644 index 0000000..70dc55b --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/repository/SectionRepository.kt @@ -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> + suspend fun getSectionById(id: String): Section? + suspend fun insertSection(section: Section) + suspend fun updateSection(section: Section) + suspend fun deleteSection(id: String) +} 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 new file mode 100644 index 0000000..a4c3752 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt @@ -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()) } + + 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, + 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) }, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt new file mode 100644 index 0000000..8eb532d --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt @@ -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) + +data class ProjectUiState( + val project: Project? = null, + val sections: List = 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) } + } +}