feat: [#24] recherche globale (debounce 300ms, min 2 chars, live results)

This commit is contained in:
2026-06-06 06:39:06 +02:00
parent 1146b146c0
commit 5fc6c8a3d4
2 changed files with 125 additions and 0 deletions
@@ -0,0 +1,85 @@
package com.planify.mobile.ui.search
import androidx.compose.foundation.layout.Column
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.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
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.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.ui.components.TaskRow
@Composable
fun SearchScreen(
onTaskClick: (Task) -> Unit,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsState()
val results by viewModel.results.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = { Text("Rechercher des tâches…") },
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(Icons.Outlined.Close, contentDescription = "Effacer")
}
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
when {
query.length < 2 -> Text(
text = "Saisissez au moins 2 caractères",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
results.isEmpty() -> Text(
text = "Aucun résultat pour « $query »",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
else -> LazyColumn {
item {
Text(
text = "${results.size} résultat(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
items(results, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
}
@@ -0,0 +1,40 @@
package com.planify.mobile.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
val query = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val results = query
.debounce(300)
.flatMapLatest { q ->
if (q.length < 2) flowOf(emptyList())
else taskRepository.searchTasks(q)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setQuery(q: String) { query.value = q }
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}