Application de Liste de Tâches Moderne avec Architecture MVI et Jetpack Compose

Dans le développement Android, une application de liste de tâches est souvent un cas d'étude classique. Cependant, nous allons ici construire une application véritablement moderne, intégrant une architecture MVI (Model-View-Intent), une interface utilisateur entièrement réalisée avec Jetpack Compose, la persistance avec Room et les coroutines Kotlin, ainsi que la prise en charge des couleurs dynamiques de Material You. Il s'agit moins d'une simple implémenttaion fonctionnelle que d'une exploration complète des technologies de pointe de l'écosystème Android.

Aperçu de la Pile Technique

L'architecture repose sur plusieurs piliers. Le pattern MVI offre un flux de données unidirectionnel, rendant l'état de l'application prévisible. Jetpack Compose permet une constrcution d'UI déclarative. La gestion asynchrone est assurée par les Coroutines et Flow. Room, couplé au processeur de symboles Kotlin (KSP), gère la persistance locale. Le système de thèmes de Material You fournit une personnalisation visuelle sur Android 12 et supérieur. La gestion des dépendances est confiée à Dagger Hilt, et la sérialisation JSON à Kotlinx Serialization.

Le choix de MVI sur le MVVM traditionnel se justifie par sa capacité à gérer des états complexes de manière cohérente. Jetpack Compose, en tant que framework UI déclaratif, s'accorde naturellement avec ce flux unidirectionnel. L'adoption de KSP à la place de KAPT offre des gains de performance significatifs lors de la compilation, un avantage notable sur les projets volumineux.

Implémentation de l'Architecture MVI

Le cœur de MVI réside dans le cycle : l'utilisateur émet une Intent, le système la traite pour produire un nouvel état (State), et l'UI se redessine en conséquence.

Modélisation de l'état avec une classe scellée :

sealed class TaskState {
    object Initial : TaskState()
    data class Loading(val feedback: String) : TaskState()
    data class Displayed(
        val taskItems: List<task>,
        val currentFilter: FilterMode = FilterMode.ALL
    ) : TaskState()
    data class Failed(val cause: Throwable) : TaskState()
}</task>

Gestionnaire d'intents et de flux d'état dans le ViewModel :

@HiltViewModel
class TaskViewModel @Inject constructor(
    private val fetchTasks: FetchTasksUseCase,
    private val createTask: CreateTaskUseCase
) : ViewModel() {

    private val _taskState = MutableStateFlow<taskstate>(TaskState.Initial)
    val taskState: StateFlow<taskstate> = _taskState.asStateFlow()

    fun handleUserIntent(intent: UserIntent) {
        when (intent) {
            is UserIntent.LoadAll -> loadAllTasks()
            is UserIntent.CreateNew -> createNewTask(intent.description)
            is UserIntent.ToggleCompletion -> toggleTaskStatus(intent.taskId)
        }
    }

    private fun loadAllTasks() {
        viewModelScope.launch {
            _taskState.value = TaskState.Loading("Chargement en cours...")
            fetchTasks()
                .onEach { tasks -> _taskState.value = TaskState.Displayed(tasks) }
                .catch { e -> _taskState.value = TaskState.Failed(e) }
                .launchIn(this)
        }
    }
}</taskstate></taskstate>

Interface Utilisateur Déclarative avec Compose

Compose transforme la construction de l'UI en une fonction de l'état actuel. L'interface réagit automatiquement aux changements.

Écran principal collectant le flux d'état :

@Composable
fun TaskAppScreen(
    viewModel: TaskViewModel = hiltViewModel()
) {
    val currentState by viewModel.taskState.collectAsStateWithLifecycle()
    val dynamicColor = isDynamicColorAvailable()

    MaterialTheme(
        colorScheme = if (dynamicColor) dynamicDarkColorScheme(LocalContext.current)
                      else defaultColorScheme()
    ) {
        Surface {
            Column {
                TaskTopAppBar()
                when (currentState) {
                    is TaskState.Loading -> LoadingIndicator((currentState as TaskState.Loading).feedback)
                    is TaskState.Displayed -> TaskListContent(
                        tasks = (currentState as TaskState.Displayed).taskItems,
                        onTaskClicked = { id -> viewModel.handleUserIntent(UserIntent.ToggleCompletion(id)) }
                    )
                    is TaskState.Failed -> ErrorMessage((currentState as TaskState.Failed).cause)
                    TaskState.Initial -> Unit
                }
            }
            FloatingActionButton(
                onClick = { /* Déclencher un intent de création */ }
            )
        }
    }
}

Liste performante et réactive :

@Composable
private fun TaskListContent(
    tasks: List<task>,
    onTaskClicked: (Long) -> Unit
) {
    LazyColumn(
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        items(
            items = tasks,
            key = { it.uniqueId }
        ) { task ->
            TaskRowItem(
                taskDescription = task.description,
                isComplete = task.isDone,
                onToggle = { onTaskClicked(task.uniqueId) },
                modifier = Modifier.animateItemPlacement()
            )
        }
    }
}</task>

Fonctionnalités Intelligentes Avancées

L'application intègre des capacités au-delà de la gestion CRUD simple.

Système de suggestion basé sur le machine learning :

class TaskSuggestionEngine(private val context: Context) {
    private val mlModel by lazy { TaskClassifier.initialize(context) }

    suspend fun proposeTasksFromText(textInput: String): List<suggestion> =
        withContext(Dispatchers.IO) {
            val analysis = mlModel.analyze(textInput)
            analysis.inferences.map { inference ->
                Suggestion(
                    prompt = inference.label,
                    estimatedEffort = inference.confidence,
                    category = mapInferenceToCategory(inference)
                )
            }
        }
}</suggestion>

Entrée vocale avec traitement du langage naturel :

@Composable
fun VoiceCommandField(onCommandParsed: (String) -> Unit) {
    val speechManager = rememberSpeechRecognizerManager()
    var isActive by remember { mutableStateOf(false) }

    IconButton(onClick = { isActive = true; speechManager.startListening() }) {
        Icon(Icons.Default.Mic, "Ajouter par la voix")
    }

    LaunchedEffect(isActive) {
        if (isActive) {
            speechManager.resultFlow.collect { text ->
                val cleanedText = nlpProcessor.extractTaskIntent(text)
                onCommandParsed(cleanedText)
                isActive = false
            }
        }
    }
}

Optimisation des Performances

Des requêtes de base de données efficaces et une composition UI optimisée sont essentielles.

DAO avec requêtes optimisées :

@Dao
interface TaskDataAccess {
    @Query("SELECT * FROM tasks WHERE status != 'DONE' ORDER BY importance DESC, timestamp DESC")
    fun observeActiveTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks LIMIT :pageSize OFFSET :startIndex")
    suspend fun loadPage(pageSize: Int, startIndex: Int): List<TaskEntity>
}

Optimisation des recompositions Compose :

@Composable
fun FilteredTaskView(taskList: List<Task>, searchQuery: String) {
    val displayedItems by remember(taskList, searchQuery) {
        derivedStateOf {
            taskList.filter { it.description.contains(searchQuery, ignoreCase = true) }
        }
    }

    LazyColumn {
        items(displayedItems, key = { it.uniqueId }) { task ->
            TaskCard(task)
        }
    }
}

Stratégie de Test

La couverture de test assure la fiabilité des composants critiques.

Test unitaire du ViewModel :

@HiltAndroidTest
class TaskViewModelTest {
    @Test
    fun `creating a task updates the state to include it`() = runTest {
        val fakeRepository = FakeTaskRepository()
        val viewModel = TaskViewModel(fakeRepository, CreateTaskUseCase(fakeRepository))

        viewModel.handleUserIntent(UserIntent.CreateNew("Nouvelle tâche test"))

        val finalState = viewModel.taskState.value
        assertIs<TaskState.Displayed>(finalState)
        assertEquals(1, finalState.taskItems.size)
        assertEquals("Nouvelle tâche test", finalState.taskItems.first().description)
    }
}

Test d'interface Compose :

@Test
fun task_list_is_displayed_after_successful_load() {
    composeTestRule.setContent {
        TaskAppScreen(viewModel = mockViewModelInState(TaskState.Displayed(sampleTasks)))
    }

    composeTestRule.onNodeWithText(sampleTasks.first().description)
        .assertIsDisplayed()
}

Étiquettes: MVI Jetpack Compose Room Kotlin Coroutines Material You

Publié le 1 juin à 13h04