feat: [#13] priorités, labels, sous-tâches (PriorityPicker, LabelPicker, SubTaskSection, LabelRepository)

This commit is contained in:
2026-06-06 06:12:32 +02:00
parent 520971ccaa
commit 0f1afda295
9 changed files with 375 additions and 0 deletions
@@ -2,6 +2,7 @@ package com.planify.mobile.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import com.planify.mobile.data.local.dao.LabelDao
import com.planify.mobile.data.local.dao.ProjectDao
import com.planify.mobile.data.local.dao.SectionDao
import com.planify.mobile.data.local.dao.TaskDao
@@ -28,4 +29,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
abstract fun projectDao(): ProjectDao
abstract fun sectionDao(): SectionDao
abstract fun labelDao(): LabelDao
}
@@ -0,0 +1,27 @@
package com.planify.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import com.planify.mobile.data.local.entity.LabelEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface LabelDao {
@Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC")
fun getAllLabels(): Flow<List<LabelEntity>>
@Query("SELECT * FROM labels WHERE id = :id")
suspend fun getById(id: String): LabelEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(label: LabelEntity)
@Update
suspend fun update(label: LabelEntity)
@Query("UPDATE labels SET is_deleted = 1 WHERE id = :id")
suspend fun softDelete(id: String)
}
@@ -0,0 +1,34 @@
package com.planify.mobile.data.repository
import com.planify.mobile.data.local.dao.LabelDao
import com.planify.mobile.data.local.entity.LabelEntity
import com.planify.mobile.domain.model.BackendType
import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.repository.LabelRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class LabelRepositoryImpl @Inject constructor(
private val dao: LabelDao,
) : LabelRepository {
override fun getAllLabels(): Flow<List<Label>> =
dao.getAllLabels().map { it.map { e -> e.toDomain() } }
override suspend fun getLabelById(id: String): Label? = dao.getById(id)?.toDomain()
override suspend fun insertLabel(label: Label) = dao.insert(label.toEntity())
override suspend fun updateLabel(label: Label) = dao.update(label.toEntity())
override suspend fun deleteLabel(id: String) = dao.softDelete(id)
private fun LabelEntity.toDomain() = Label(
id = id, name = name, color = color, order = order, sourceId = sourceId,
backendType = runCatching { BackendType.valueOf(backendType) }.getOrDefault(BackendType.LOCAL),
isFavorite = isFavorite, isDeleted = isDeleted,
)
private fun Label.toEntity() = LabelEntity(
id = id, name = name, color = color, order = order, sourceId = sourceId,
backendType = backendType.name, isFavorite = isFavorite, isDeleted = isDeleted,
)
}
@@ -24,4 +24,5 @@ object DatabaseModule {
@Provides fun provideTaskDao(db: AppDatabase) = db.taskDao()
@Provides fun provideProjectDao(db: AppDatabase) = db.projectDao()
@Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao()
@Provides fun provideLabelDao(db: AppDatabase) = db.labelDao()
}
@@ -1,8 +1,10 @@
package com.planify.mobile.di
import com.planify.mobile.data.repository.LabelRepositoryImpl
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.LabelRepository
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SectionRepository
import com.planify.mobile.domain.repository.TaskRepository
@@ -24,4 +26,7 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindSectionRepository(impl: SectionRepositoryImpl): SectionRepository
@Binds @Singleton
abstract fun bindLabelRepository(impl: LabelRepositoryImpl): LabelRepository
}
@@ -0,0 +1,12 @@
package com.planify.mobile.domain.repository
import com.planify.mobile.domain.model.Label
import kotlinx.coroutines.flow.Flow
interface LabelRepository {
fun getAllLabels(): Flow<List<Label>>
suspend fun getLabelById(id: String): Label?
suspend fun insertLabel(label: Label)
suspend fun updateLabel(label: Label)
suspend fun deleteLabel(id: String)
}
@@ -0,0 +1,116 @@
package com.planify.mobile.ui.task
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.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.outlined.Label
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.repository.LabelRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class LabelPickerViewModel @Inject constructor(
labelRepository: LabelRepository,
) : ViewModel() {
val labels = labelRepository.getAllLabels()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelPickerSheet(
selectedLabels: List<String>,
onConfirm: (List<String>) -> Unit,
onDismiss: () -> Unit,
viewModel: LabelPickerViewModel = hiltViewModel(),
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val labels by viewModel.labels.collectAsState()
var selected by remember { mutableStateOf(selectedLabels.toSet()) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Labels",
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { onConfirm(selected.toList()) }) { Text("OK") }
}
LazyColumn {
items(labels, key = { it.id }) { label ->
LabelRow(
label = label,
checked = label.name in selected,
onToggle = {
selected = if (label.name in selected) selected - label.name else selected + label.name
},
)
}
if (labels.isEmpty()) {
item {
Text(
"Aucun label. Créez-en un dans les paramètres.",
modifier = Modifier.padding(16.dp),
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun LabelRow(label: Label, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Outlined.Label,
contentDescription = null,
tint = runCatching { Color(android.graphics.Color.parseColor(label.color)) }.getOrDefault(Color.Gray),
)
Spacer(Modifier.width(12.dp))
Text(text = label.name, modifier = Modifier.weight(1f))
Checkbox(checked = checked, onCheckedChange = { onToggle() })
}
}
@@ -0,0 +1,65 @@
package com.planify.mobile.ui.task
import androidx.compose.foundation.clickable
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.planify.mobile.ui.components.PriorityBadge
import com.planify.mobile.ui.components.priorityColor
private val priorityLabels = mapOf(
1 to "Urgente",
2 to "Haute",
3 to "Moyenne",
4 to "Aucune",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriorityPickerSheet(
current: Int,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.padding(bottom = 16.dp)) {
Text(
text = "Priorité",
style = androidx.compose.material3.MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
listOf(1, 2, 3, 4).forEach { priority ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(priority); onDismiss() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PriorityBadge(priority = priority)
Spacer(Modifier.width(12.dp))
Text(
text = priorityLabels[priority] ?: "",
color = priorityColor[priority] ?: androidx.compose.ui.graphics.Color.Gray,
modifier = Modifier.weight(1f),
)
RadioButton(selected = current == priority, onClick = null)
}
}
}
}
}
@@ -0,0 +1,113 @@
package com.planify.mobile.ui.task
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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.planify.mobile.domain.model.Task
@Composable
fun SubTaskSection(
subTasks: List<Task>,
onAddSubTask: (String) -> Unit,
onToggleSubTask: (Task) -> Unit,
onDeleteSubTask: (Task) -> Unit,
modifier: Modifier = Modifier,
) {
var newSubTaskContent by remember { mutableStateOf("") }
var showInput by remember { mutableStateOf(false) }
Column(modifier = modifier) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Sous-tâches (${subTasks.count { it.checked }}/${subTasks.size})",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
}
subTasks.forEach { subTask ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = subTask.checked,
onCheckedChange = { onToggleSubTask(subTask) },
)
Text(
text = subTask.content,
style = MaterialTheme.typography.bodyMedium,
textDecoration = if (subTask.checked) TextDecoration.LineThrough else null,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onDeleteSubTask(subTask) }) {
Icon(Icons.Outlined.Close, contentDescription = "Supprimer")
}
}
}
if (showInput) {
Row(
modifier = Modifier.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(40.dp))
OutlinedTextField(
value = newSubTaskContent,
onValueChange = { newSubTaskContent = it },
placeholder = { Text("Nouvelle sous-tâche") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
if (newSubTaskContent.isNotBlank()) {
onAddSubTask(newSubTaskContent.trim())
newSubTaskContent = ""
}
showInput = false
}),
)
}
}
TextButton(
onClick = { showInput = true },
modifier = Modifier.padding(start = 8.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Ajouter une sous-tâche")
}
}
}