Pattern MVI

Model-View-Intent : le pattern d’architecture UI de Redface 2.


Principe

MVI impose un flux de données unidirectionnel (UDF — Unidirectional Data Flow). L’utilisateur émet des Intents, le ViewModel produit un nouveau State, Compose dessine le State.

graph LR
    A["UI (Compose)"] -->|"Intent"| B["ViewModel"]
    B -->|"State"| A
    B -->|"Effect"| C["Navigation / Toast / ..."]

Trois concepts :

  • State : l’état complet de l’écran. Immutable. Un seul objet data class.
  • Intent : une action de l’utilisateur. sealed interface. Pur, sans logique.
  • Effect : un événement one-shot (navigation, snackbar, vibration). Ne fait pas partie du state car il ne doit pas être rejoué à la recomposition.

Note terminologique

Ce que ce document appelle “MVI” est techniquement du MVVM + UDF — le pattern recommandé par Google pour Compose. La distinction est principalement terminologique :

  • MVVM classique : ViewModel expose des LiveData/StateFlow, la View observe. Le flux peut être bidirectionnel.
  • MVI / MVVM+UDF : le flux est strictement unidirectionnel. Les actions passent par des Intents (ou Events), le ViewModel produit un nouveau State immutable. C’est ce que fait ce projet.

Le code est le même. On utilise le terme “MVI” dans ce projet par convention, mais un développeur habitué au MVVM Android retrouvera ses repères.


Écran Drapeaux (accueil)

// ── State ──────────────────────────────────────────
data class FlagsState(
    val flags: List<FlaggedTopic> = emptyList(),
    val filteredFlags: List<FlaggedTopic> = emptyList(),
    val sortMode: SortMode = SortMode.BY_DATE,
    val filter: FlagFilter = FlagFilter.ALL,
    val isLoading: Boolean = false,
    val isRefreshing: Boolean = false,
    val error: String? = null,
)

enum class SortMode { BY_DATE, BY_CATEGORY }
enum class FlagFilter { ALL, CYAN, FAVORITE, READ }

// ── Intents ────────────────────────────────────────
sealed interface FlagsIntent {
    data object Refresh : FlagsIntent
    data class SetSort(val mode: SortMode) : FlagsIntent
    data class SetFilter(val filter: FlagFilter) : FlagsIntent
    data class OpenTopic(val topic: FlaggedTopic) : FlagsIntent
    data class RemoveFlag(val topic: FlaggedTopic) : FlagsIntent
    data class UndoRemoveFlag(val topic: FlaggedTopic) : FlagsIntent
}

// ── Effects ────────────────────────────────────────
sealed interface FlagsEffect {
    data class NavigateToTopic(val cat: Int, val post: Int, val page: Int) : FlagsEffect
    data class ShowUndo(val topic: FlaggedTopic) : FlagsEffect
    data class Error(val message: String) : FlagsEffect
}

ViewModel

@HiltViewModel
class FlagsViewModel @Inject constructor(
    private val flagRepository: FlagRepository,
) : ViewModel() {

    private val _state = MutableStateFlow(FlagsState())
    val state = _state.asStateFlow()

    private val _effects = Channel<FlagsEffect>()
    val effects = _effects.receiveAsFlow()

    init { send(FlagsIntent.Refresh) }

    fun send(intent: FlagsIntent) {
        when (intent) {
            is FlagsIntent.Refresh -> refresh()
            is FlagsIntent.SetSort -> {
                _state.update { it.copy(sortMode = intent.mode) }
                updateFilteredFlags()
            }
            is FlagsIntent.SetFilter -> {
                _state.update { it.copy(filter = intent.filter) }
                updateFilteredFlags()
            }
            is FlagsIntent.OpenTopic -> openTopic(intent.topic)
            is FlagsIntent.RemoveFlag -> removeFlag(intent.topic)
            is FlagsIntent.UndoRemoveFlag -> undoRemoveFlag(intent.topic)
        }
    }

    private fun refresh() {
        viewModelScope.launch {
            _state.update { it.copy(isRefreshing = true) }
            flagRepository.getFlags()
                .onSuccess { flags ->
                    _state.update { it.copy(flags = flags, isRefreshing = false, error = null) }
                    updateFilteredFlags()
                }
                .onFailure { e ->
                    _state.update { it.copy(isRefreshing = false, error = e.message) }
                }
        }
    }

    private fun openTopic(topic: FlaggedTopic) {
        viewModelScope.launch {
            _effects.send(FlagsEffect.NavigateToTopic(topic.cat, topic.postId, topic.lastReadPage))
        }
    }

    private val pendingRemovals = mutableMapOf<Int, Job>()

    private fun removeFlag(topic: FlaggedTopic) {
        // 1. Retirer de l'UI immédiatement
        _state.update { it.copy(flags = it.flags - topic) }
        updateFilteredFlags()

        // 2. Lancer un timer avec undo
        val job = viewModelScope.launch {
            _effects.send(FlagsEffect.ShowUndo(topic))
            delay(5_000)

            // 3. Timeout expiré → exécuter côté serveur
            flagRepository.removeFlag(topic)
                .onFailure {
                    // Rollback si le réseau échoue
                    _state.update { it.copy(flags = it.flags + topic) }
                    updateFilteredFlags()
                    _effects.send(FlagsEffect.Error("Impossible de retirer le drapeau"))
                }
        }
        pendingRemovals[topic.postId] = job
    }

    private fun undoRemoveFlag(topic: FlaggedTopic) {
        pendingRemovals.remove(topic.postId)?.cancel()
        _state.update { it.copy(flags = it.flags + topic) }
        updateFilteredFlags()
    }

    private fun updateFilteredFlags() {
        _state.update { state ->
            val filtered = state.flags
                .filter { matchesFilter(it, state.filter) }
                .sortedWith(comparatorFor(state.sortMode))
            state.copy(filteredFlags = filtered)
        }
    }
}

Screen (Compose)

@Composable
fun FlagsScreen(
    viewModel: FlagsViewModel = hiltViewModel(),
    onNavigateToTopic: (cat: Int, post: Int, page: Int) -> Unit,
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    // Effects one-shot, lifecycle-aware (ne traite pas en arrière-plan)
    ObserveAsEvents(viewModel.effects) { effect ->
        when (effect) {
            is FlagsEffect.NavigateToTopic ->
                onNavigateToTopic(effect.cat, effect.post, effect.page)
            is FlagsEffect.ShowUndo -> { /* snackbar */ }
            is FlagsEffect.Error -> { /* snackbar */ }
        }
    }

    FlagsContent(
        state = state,
        onIntent = viewModel::send,
    )
}

@Composable
private fun FlagsContent(
    state: FlagsState,
    onIntent: (FlagsIntent) -> Unit,
) {
    Column {
        // Toolbar : tri + filtre
        FlagsToolbar(
            sortMode = state.sortMode,
            filter = state.filter,
            onSortChange = { onIntent(FlagsIntent.SetSort(it)) },
            onFilterChange = { onIntent(FlagsIntent.SetFilter(it)) },
        )

        // Liste des drapeaux
        PullToRefreshBox(
            isRefreshing = state.isRefreshing,
            onRefresh = { onIntent(FlagsIntent.Refresh) },
        ) {
            LazyColumn {
                items(state.filteredFlags) { topic ->
                    FlagItem(
                        topic = topic,
                        onClick = { onIntent(FlagsIntent.OpenTopic(topic)) },
                        onDismiss = { onIntent(FlagsIntent.RemoveFlag(topic)) },
                    )
                }
            }
        }
    }
}

Écran Topic (lecture)

data class TopicState(
    val title: String = "",
    val posts: List<Post> = emptyList(),
    val currentPage: Int = 1,
    val totalPages: Int = 1,
    val isLoading: Boolean = false,
    val isFirstPostOwner: Boolean = false,
    val poll: Poll? = null,
    val error: String? = null,
)

sealed interface TopicIntent {
    data class LoadPage(val page: Int) : TopicIntent
    data object NextPage : TopicIntent
    data object PrevPage : TopicIntent
    data object Refresh : TopicIntent
    data class QuotePost(val numreponse: Int) : TopicIntent
    data class EditPost(val numreponse: Int) : TopicIntent
    data object EditFirstPost : TopicIntent
    data class FlagTopic(val type: FlagType) : TopicIntent
    data class OpenImage(val url: String) : TopicIntent
}

sealed interface TopicEffect {
    data class NavigateToReply(val cat: Int, val post: Int, val quote: String?) : TopicEffect
    data class NavigateToEdit(val cat: Int, val post: Int, val numreponse: Int) : TopicEffect
    data class NavigateToEditFP(val cat: Int, val post: Int) : TopicEffect
    data class NavigateToImage(val url: String) : TopicEffect
    data class Error(val message: String) : TopicEffect
}

Écran Editor (reply / edit / FP)

L’éditeur est partagé entre reply, edit et edit FP. Le mode détermine les champs visibles.

data class EditorState(
    val mode: EditorMode = EditorMode.Reply,
    val content: String = "",
    val subject: String = "",           // visible en mode EditFP
    val poll: PollData? = null,         // visible en mode EditFP
    val isSending: Boolean = false,
    val preview: String? = null,        // rendu BBCode preview
    val error: String? = null,
)

enum class EditorMode {
    Reply,      // nouveau message
    Edit,       // éditer un post existant
    EditFP,     // éditer le first post (sujet + sondage)
    NewTopic,   // créer un topic
}

sealed interface EditorIntent {
    data class UpdateContent(val text: String) : EditorIntent
    data class UpdateSubject(val text: String) : EditorIntent
    data class InsertBBCode(val tag: String) : EditorIntent
    data object Preview : EditorIntent
    data object Send : EditorIntent
}

Écran Messages

data class MessagesState(
    val activeTab: MessageTab = MessageTab.CLASSIC,
    val classicMPs: List<PrivateMessage> = emptyList(),
    val multiMPs: List<PrivateMessage> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null,
)

enum class MessageTab { CLASSIC, MULTI }

sealed interface MessagesIntent {
    data class SwitchTab(val tab: MessageTab) : MessagesIntent
    data object Refresh : MessagesIntent
    data class OpenMP(val mp: PrivateMessage) : MessagesIntent
    data object NewMP : MessagesIntent
    data object NewMultiMP : MessagesIntent
}

Convention

Chaque feature suit la même structure de fichiers :

feature/topic/
  ├── TopicScreen.kt        // @Composable, collecte state + effects
  ├── TopicContent.kt       // @Composable stateless, previewable
  ├── TopicViewModel.kt     // MVI ViewModel
  └── TopicState.kt         // State + Intent + Effect

Cette convention garantit la cohérence et facilite l’onboarding des contributeurs.


Utilitaire : ObserveAsEvents

Helper lifecycle-aware pour collecter les effects sans les traiter en arrière-plan. Vit dans :core:ui et est utilisé par tous les screens.

@Composable
fun <T> ObserveAsEvents(
    flow: Flow<T>,
    onEvent: (T) -> Unit,
) {
    val lifecycleOwner = LocalLifecycleOwner.current
    LaunchedEffect(flow, lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            flow.collect(onEvent)
        }
    }
}

Sans ce helper, les effects émis pendant que l’app est en arrière-plan seraient traités immédiatement (navigation fantôme, snackbars invisibles). repeatOnLifecycle(STARTED) garantit que les effects ne sont consommés que quand l’écran est au premier plan.


Haut de page

Redface 2 — Specs v0.3.1 — Un projet communautaire pour Hardware.fr

This site uses Just the Docs, a documentation theme for Jekyll.