feat: [#15] fiche projet (ProjectEditSheet + ProjectEditViewModel, couleurs, vue liste/tableau)
This commit is contained in:
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user