feat: [#15] fiche projet (ProjectEditSheet + ProjectEditViewModel, couleurs, vue liste/tableau)

This commit is contained in:
2026-06-06 06:20:19 +02:00
parent 5049d4d681
commit 933704ca91
2 changed files with 280 additions and 0 deletions
@@ -0,0 +1,167 @@
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material.icons.outlined.StarBorder
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.ViewStyle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProjectEditSheet(
projectId: String? = null,
onDismiss: () -> Unit,
onSaved: (projectId: String) -> Unit = {},
onDeleted: () -> Unit = {},
viewModel: ProjectEditViewModel = hiltViewModel(),
) {
LaunchedEffect(projectId) { viewModel.init(projectId) }
val state by viewModel.state.collectAsState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.padding(horizontal = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (projectId == null) "Nouveau projet" else "Modifier le projet",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { viewModel.toggleFavorite() }) {
Icon(
imageVector = if (state.isFavorite) Icons.Outlined.Star else Icons.Outlined.StarBorder,
contentDescription = "Favori",
tint = if (state.isFavorite) Color(0xFFFFC107) else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (projectId != null) {
IconButton(onClick = { viewModel.delete { onDeleted(); onDismiss() } }) {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error)
}
}
}
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.name,
onValueChange = viewModel::setName,
placeholder = { Text("Nom du projet") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(parseColor(state.color)),
)
},
)
Spacer(Modifier.height(16.dp))
Text("Couleur", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier.height(80.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(viewModel.colors) { color ->
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(parseColor(color))
.then(
if (color == state.color) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape)
else Modifier
)
.clickable { viewModel.setColor(color) },
)
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Text("Vue par défaut", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ViewStyle.entries.forEachIndexed { index, style ->
SegmentedButton(
selected = state.viewStyle == style,
onClick = { viewModel.setViewStyle(style) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = ViewStyle.entries.size),
label = { Text(if (style == ViewStyle.LIST) "Liste" else "Tableau") },
)
}
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Button(
onClick = { viewModel.save(onDone = { id -> onSaved(id); onDismiss() }) },
enabled = state.name.isNotBlank() && !state.isSaving,
) {
Text(if (projectId == null) "Créer" else "Enregistrer")
}
}
Spacer(Modifier.height(16.dp))
}
}
}
private fun parseColor(hex: String): Color = runCatching {
Color(android.graphics.Color.parseColor(hex))
}.getOrDefault(Color.Gray)
@@ -0,0 +1,113 @@
package com.planify.mobile.ui.project
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
data class ProjectEditState(
val projectId: String? = null,
val name: String = "",
val color: String = "#4CAF50",
val emoji: String? = null,
val isFavorite: Boolean = false,
val viewStyle: ViewStyle = ViewStyle.LIST,
val isSaving: Boolean = false,
)
private val defaultColors = listOf(
"#F44336", "#E91E63", "#9C27B0", "#3F51B5",
"#2196F3", "#00BCD4", "#4CAF50", "#8BC34A",
"#FFEB3B", "#FF9800", "#795548", "#607D8B",
)
@HiltViewModel
class ProjectEditViewModel @Inject constructor(
private val projectRepository: ProjectRepository,
) : ViewModel() {
val colors = defaultColors
private val _state = MutableStateFlow(ProjectEditState())
val state = _state.asStateFlow()
fun init(projectId: String?) {
if (projectId != null) {
viewModelScope.launch {
val project = projectRepository.getProjectById(projectId) ?: return@launch
_state.update {
it.copy(
projectId = projectId,
name = project.name,
color = project.color,
emoji = project.emoji,
isFavorite = project.isFavorite,
viewStyle = project.viewStyle,
)
}
}
}
}
fun setName(name: String) = _state.update { it.copy(name = name) }
fun setColor(color: String) = _state.update { it.copy(color = color) }
fun setEmoji(emoji: String?) = _state.update { it.copy(emoji = emoji) }
fun toggleFavorite() = _state.update { it.copy(isFavorite = !it.isFavorite) }
fun setViewStyle(style: ViewStyle) = _state.update { it.copy(viewStyle = style) }
fun save(onDone: (projectId: String) -> Unit) {
val st = _state.value
if (st.name.isBlank()) return
_state.update { it.copy(isSaving = true) }
viewModelScope.launch {
val id = st.projectId ?: UUID.randomUUID().toString()
if (st.projectId != null) {
val existing = projectRepository.getProjectById(id)
if (existing != null) {
projectRepository.updateProject(
existing.copy(
name = st.name,
color = st.color,
emoji = st.emoji,
isFavorite = st.isFavorite,
viewStyle = st.viewStyle,
)
)
}
} else {
projectRepository.insertProject(
Project(
id = id,
name = st.name,
color = st.color,
emoji = st.emoji,
isFavorite = st.isFavorite,
viewStyle = st.viewStyle,
backendType = BackendType.LOCAL,
sortedBy = SortBy.MANUAL,
)
)
}
_state.update { it.copy(isSaving = false) }
onDone(id)
}
}
fun delete(onDone: () -> Unit) {
val id = _state.value.projectId ?: return
viewModelScope.launch {
projectRepository.deleteProject(id)
onDone()
}
}
}