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.
Méthodologie MVI hybride
Conformément à la méthodologie triple-hybride (SDD + Prototype + TDD) :
- Spec les contrats (types
State,Intent,Effect) — c’est le contrat public du ViewModel, utile pour le Screen et les tests. Ces types sont documentés ci-dessous pour chaque écran. - TDD les helpers purs (
matchesFilter,comparatorFor, mappers, reducers déterministes). Red → Green → Refactor, testables isolément. - Prototype le Screen Compose. L’UI émerge du code, pas de la spec — l’exemple complet ci-dessous montre les patterns (
send(intent),ObserveAsEvents,PullToRefreshBox) mais la mise en page réelle est itérée à partir de la Phase 1.
Les exemples ViewModel ci-dessous sont des squelettes illustratifs — certains détails (timer 5 s, rollback, mutex) sont documentés parce qu’ils encodent des patterns non-triviaux, pas parce qu’ils sont figés dans la pierre.
Écran Drapeaux (accueil)
Statut Phase 2 finish livré : le
FlagsViewModelréel expose plusieursStateFlowséparés (auth, onglet courantFlagTab, liste de drapeaux du tab,isRefreshing,removeFlagState/removeFlagEvent) plutôt qu’un seulFlagsStateagrégé. Onglets viaFlagTab(Cyan/Red/Favorite + Super, placeholder « super favoris » sans backend,flagType == null). Le filtre « non-lus uniquement » est désormais par type de drapeau (#317, persisté en préférences, défaut type-aware : CYAN activé, RED/FAVORITE non) ; il se pilote par le bottom sheet d’affichage et, pour CYAN, par re-tap de l’onglet (raccourci « +lus » ; leFilterChipa été retiré). Le retrait d’un drapeau (#99,delflag.php, confirmation + pas d’undo optimiste) est livré. Le pull-to-refresh (PullToRefreshBoxM3) a remplacé le bouton « Actualiser ».logout()ne vit plus ici (déplacé dansAppAccountViewModel, #198). Le squelette illustratif ci-dessous reflète la forme shippée.
ViewModel — forme livrée
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FlagsViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val flagRepository: FlagRepository,
private val userPreferencesRepository: UserPreferencesRepository,
) : ViewModel() {
private var observedPseudo: String? = null
// Phase 2 finish — 4 onglets via FlagTab (Cyan/Red/Favorite/Super). FlagTab.flagType est
// nullable : Super est un placeholder « super favoris à venir » sans backend (flagType == null
// → aucun fetch). Les 3 autres mappent vers FlagType.
private val _selectedTab = MutableStateFlow<FlagTab>(FlagTab.Cyan)
val selectedTab: StateFlow<FlagTab> = _selectedTab.asStateFlow()
// #317 — filtre « non-lus uniquement » par type de drapeau, persisté (DataStore) et résolu avec
// un défaut type-aware (CYAN → true = sous-ensemble actionnable, RED/FAVORITE → false). Le toggle
// est déclenché soit par le bottom sheet d'affichage, soit par un re-tap de l'onglet Cyan déjà
// sélectionné (raccourci « +lus »). Aucun état en mémoire : la valeur vit dans les préférences.
// Anchored over the existing list during the user-driven pull-to-refresh round-trip
// (Material 3 PullToRefreshBox a remplacé le bouton « Actualiser »). Même pattern que ForumViewModel.
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// #99 — retrait d'un drapeau : confirmation (RemoveFlagState Idle/Confirming/Removing) +
// évènement one-shot (RemoveFlagEvent Success/Failure) consommé par l'écran pour un snackbar.
val removeFlagState: StateFlow<RemoveFlagState> // = _removeFlagState.asStateFlow()
val removeFlagEvent: StateFlow<RemoveFlagEvent?> // = _removeFlagEvent.asStateFlow()
val authState: StateFlow<AuthState?> =
authRepository.observeAuthState()
.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = null)
val flagsState: StateFlow<FlagsResult?> = authState
.onEach(::clearFlagsCacheIfSessionChanged)
.flatMapLatest { state ->
when (state) {
null, AuthState.Anonymous -> flowOf<FlagsResult?>(null)
is AuthState.Authenticated -> selectedTab.flatMapLatest { tab ->
when (val type = tab.flagType) {
null -> flowOf<FlagsResult?>(null) // Super placeholder : pas de fetch
else -> combine(
flagRepository.observe(type),
userPreferencesRepository.observeFlagsViewSettings(type)
.map { it.unreadOnly }
.distinctUntilChanged(),
) { result, unreadOnly -> filterUnreadOnly(result, unreadOnly) }
}
}
}
}
.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = null)
// #317 — réglages d'affichage résolus pour l'onglet courant ; le re-tap y lit `unreadOnly`.
// initialValue type-aware (CYAN → true) pour éviter un flash « +lus » au démarrage à froid.
val flagsViewSettings: StateFlow<FlagsViewSettings> = selectedTab
.flatMapLatest { tab ->
tab.flagType?.let { userPreferencesRepository.observeFlagsViewSettings(it) }
?: flowOf(FlagsViewSettings())
}
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
FlagsViewSettings(unreadOnly = _selectedTab.value == FlagTab.Cyan),
)
// Re-tap Cyan déjà sélectionné → toggle du filtre via le setter (point de mutation unique).
// NB prod : le VM réel lit un `cyanUnreadOnly` dédié (StateFlow CYAN-scopé, shim optimiste +
// défaut eager `true`) plutôt que `flagsViewSettings.value`, pour éviter qu'un changement
// d'onglet ou un double re-tap rapide ne lise une valeur en retard sur DataStore (cf. #309/#317).
fun selectTab(tab: FlagTab) {
if (tab == FlagTab.Cyan && _selectedTab.value == FlagTab.Cyan) {
setFlagsUnreadOnly(!flagsViewSettings.value.unreadOnly)
return
}
_selectedTab.value = tab
}
// Écrit la valeur per-type de l'onglet courant (toujours par type ; no-op sur Super).
fun setFlagsUnreadOnly(enabled: Boolean) {
val type = _selectedTab.value.flagType ?: return
viewModelScope.launch { userPreferencesRepository.setFlagsUnreadOnlyForType(type, enabled) }
}
fun refresh() { // no-op sur Super (flagType == null)
val type = _selectedTab.value.flagType ?: return
viewModelScope.launch {
_isRefreshing.value = true
try { flagRepository.refresh(type) } finally { _isRefreshing.value = false }
}
}
// requestRemoveFlag(flag) → Confirming ; confirmRemoveFlag() → Removing → flagRepository.removeFlag
// → one-shot event ; consumeRemoveFlagEvent() après affichage du snackbar.
// NB : logout() ne vit plus ici — déplacé dans AppAccountViewModel (#198, menu compte global).
// #317 — filtre générique : ne garde que les sujets non lus quand `unreadOnly` est actif (tout
// type ; CYAN l'active par défaut, RED/FAVORITE non — cf. défaut type-aware côté repository).
private fun filterUnreadOnly(result: FlagsResult, unreadOnly: Boolean): FlagsResult {
if (!unreadOnly) return result
return when (result) {
is FlagsResult.Success -> result.copy(flags = result.flags.filter { it.hasUnread })
else -> result
}
}
private fun clearFlagsCacheIfSessionChanged(state: AuthState?) {
when (state) {
null -> Unit
AuthState.Anonymous -> {
observedPseudo = null
flagRepository.clearSessionCache()
}
is AuthState.Authenticated -> {
if (observedPseudo != state.pseudo) {
flagRepository.clearSessionCache()
}
observedPseudo = state.pseudo
}
}
}
}
Polish #154 → #198 —
MessagesRepositoryn’est plus injectée dansFlagsViewModel: l’ancienunreadMpCountétait surfacé dans le footer Drapeaux mais ce footer (pseudo, logout, version, signalement, Diagnostics) est passé parMessagesScreen(#154) avant d’être hoisté en Phase 2 finish (#198) dans le menu compte global (RedfaceAccountMenu, alimenté parAppAccountViewModel) accessible depuis chaque écran principal. Le compteur MP reviendra à côté de la liste réelle des MPs, pas en tant qu’overlay temporaire.
FlagRepository est livrée comme un contrat à deux verbes (cf. core/domain/.../FlagRepository.kt) :
interface FlagRepository {
fun observe(type: FlagType): Flow<FlagsResult>
suspend fun refresh(type: FlagType)
fun clearSessionCache()
}
sealed class FlagsResult {
data object Loading : FlagsResult()
data class Success(val flags: List<Flag>) : FlagsResult()
data class Failure(val cause: Throwable) : FlagsResult()
}
Les noms de champs (Flag.cat, Flag.topicId, Flag.type, Flag.replyCount, Flag.totalPages, Flag.lastReadPage, …) suivent strictement models.md. Pas de topic.postId ni topic.flagType ni topic.lastDate — ces noms n’existent pas dans le modèle.
Cible future (Phase 1D / Phase 2)
Quand le besoin arrive, on pourra élargir le contrat :
- ajout d’intents
RemoveFlag/UndoRemoveFlag(avec timerdelay(5_000)+ rollback réseau, pattern documenté dans:feature:topic), - pré-calcul UI
filteredFlagsderived avec unSortMode/FlagFilter, PullToRefreshBoxMaterial 3 sur leLazyColumn(l’API Phase 1B se contente d’un bouton « Réessayer » sur état d’erreur).
Quand cette extension arrive, FlagsState agrégé peut redevenir préférable au triplet de StateFlow actuel ; ce sera un changement scope au moment du chantier, documenté ici à ce moment-là — pas avant.
Screen (Compose) — forme livrée
feature/flags/src/main/kotlin/.../FlagsRoute.kt est l’entrée stateful (récupère FlagsViewModel via hiltViewModel(), collecte ses StateFlow via collectAsStateWithLifecycle()). Depuis le polish #154 et la refonte Phase 2 finish, FlagsRoute se concentre sur la liste : 4 onglets (Cyan / Lu / Favoris / Super placeholder), filtre « non-lus uniquement » par type (#317, via le bottom sheet d’affichage ou, pour CYAN, le re-tap de l’onglet), pull-to-refresh PullToRefreshBox (plus de bouton Actualiser), retrait d’un drapeau par swipe-to-remove (SwipeToDismissBox M3, swipe end-to-start) qui ouvre le dialog de confirmation (#99) — le swipe ne supprime jamais la ligne seul (la ligne est ramenée à Settled via reset(), la suppression réelle n’a lieu qu’après confirmation, quand le repo évince l’item du cache), branche login si anonyme, branche reconnect si SessionExpiredException. Pas de footer alpha — les actions compte (pseudo / logout) et outils (Diagnostics, signalement, version) vivent depuis #198 dans le menu compte global injecté via topBarActions: @Composable (() -> Unit)? = null. Le découpage <Name>Screen / <Name>Content reste l’objectif quand la complexité justifie le coût (filtre, tri, undo) — cf. cible Phase 2.
Écran Topic (lecture)
Statut Phase 1A : le
TopicUiStateréellement exposé parfeature/topic/.../TopicUiState.ktest aujourd’hui(request: TopicRequest, mode: Mode, availablePages: List<Int>)avecMode = Loading | Loaded(topic) | Error(message), et l’unique intent estRetry. Le ViewModel collecteTopicRepository.observeTopicPage(...)(cache-aside : émet le cache puis le fresh) et calculeavailablePages = (1..topic.totalPages).toList()à chaque émission. Le contrat ci-dessous est la cible Phase 1 fin / Phase 2 quand pull-to-refresh, edit FP, flag et image viewer arriveront. La navigation de page est désormais route-driven (le swipe gauche/droite #282 et les contrôles de pager appellent tousonOpenPage(targetPage), qui remplace laTopicRoutecourante — ce n’est pas un intent duTopicUiState) ; les actions sur posts (réponse, édition, viewer image) restent la cible Phase 2. Cohérent avec la méthodologie hybride (squelette illustratif, pas figé).Statut Phase 1D-2 (#107) + Phase 2 (#200) :
TopicEffect.ScrollToPost(deep link + post-submit avec numreponse extrait) etTopicEffect.ScrollToEndOfPage+TopicEffect.PostSubmitRefreshFailed(post-submit, cf. #200) sont livrés.ScrollToPostpilote le scroll one-shot vers unnumreponseconnu ;ScrollToEndOfPageest émis pour la plain reply (HFR anchor#bas, numreponse non extractible) afin que l’utilisateur voie son post en bas de la page rafraîchie ;PostSubmitRefreshFailedest émis quand le force-refresh post-submit a échoué et que la screen doit prévenir l’utilisateur (Toast côtéTopicScreen) que la soumission est partie côté HFR mais que la page locale n’a pas pu être rafraîchie. Les autres effets listés ci-dessous (NavigateToReply,NavigateToEdit,NavigateToEditFirstPost,NavigateToImage,Error) restent du contrat cible Phase 2 — ils ne sont ni émis ni câblés tant que les actions correspondantes (réponse, édition, viewer image, surface d’erreur) n’arrivent pas.
data class TopicUiState(
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 {
/** Phase 1D-2 (#107) — one-shot scroll demand consumed by `LaunchedEffect(Unit)`. */
data class ScrollToPost(val numreponse: Int) : TopicEffect
/** Phase 2 (#200) — scroll to the last post on the force-refreshed page when HFR anchored `#bas`. */
data object ScrollToEndOfPage : TopicEffect
/** Phase 2 (#200) — Toast trigger when the post-submit force refresh failed but HFR accepted the post. */
data object PostSubmitRefreshFailed : 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 NavigateToEditFirstPost(val cat: Int, val post: Int) : TopicEffect
data class NavigateToImage(val url: String) : TopicEffect
data class Error(val message: String) : TopicEffect
}
Écran Editor (post-level reply / edit) + formulaire de topic
Phase 2B-A (#86 + #144) a séparé l’éditeur en deux familles plutôt qu’en un mode unique. Phase 2C ajoute les deux mutations HFR principales : le submit reply (#145, via bddpost.php) et la quote MVP (#146, même endpoint POST mais GET form via numrep={cited}&ref={N}). Phase 2D ajoute deux écrans complémentaires : PostEditorMode.Edit (#147) édite un post arbitraire via message.php?…&numreponse={N} + POST bdd.php ; TopicFormMode.EditFirstPost (#148) édite le premier post d’un topic, exposant en plus sujet et subcat (le formulaire topic-level vit dans TopicFormScreen). Phase 2E (#149) ajoute la création d’un nouveau topic via TopicFormMode.New : FAB sur ForumCategoryScreen (visible en AuthState.Authenticated uniquement), composer TopicFormScreen mode New (sujet + dropdown sous-catégorie obligatoire + BBCode), POST bddpost.php. Le sondage à la création reste hors scope tant qu’une fixture POST sondage n’a pas été capturée.
// Post-level editor — édition de niveau post (contenu BBCode seulement)
enum class PostEditorMode { Reply, Edit }
// Topic-level form — sujet + cat/subcat + contenu + sondage
enum class TopicFormMode { New, EditFirstPost }
data class PostEditorState(
val mode: PostEditorMode,
val cat: Int,
val topicId: Int?,
val numreponse: Int?,
val page: Int?, // (Phase 2C) page topic en cours
val subcat: Int?, // (Phase 2C) sous-cat HFR, requis pour reply
val quotedNumreponse: Int? = null, // (Phase 2C #146) numreponse cité ; null = reply, non-null = quote
val quoteRef: Int? = null, // (Phase 2C #146/#227) ref opaque parsé depuis le href quote quand disponible ; null accepté sur quote obfusquée
val draft: TextFieldValue = TextFieldValue(),
val preview: PostContent = PostContent(blocks = emptyList()),
val isPreviewVisible: Boolean = false,
val validation: BbcodeValidation = BbcodeValidation.Idle,
val isLoadingForm: Boolean = false, // GET message.php avant submit
val isSubmitting: Boolean = false, // POST bddpost.php (reply/quote) ou bdd.php (edit) en cours
val submitError: SubmitError? = null,
val draftHydratedFromForm: Boolean = false, // (Phase 2C #146) draft initialisé une fois depuis ReplyForm.initialContent
)
sealed interface PostEditorIntent {
data class ContentChanged(val value: TextFieldValue) : PostEditorIntent
data class ToolbarActionClicked(val action: BbcodeAction) : PostEditorIntent
data object TogglePreview : PostEditorIntent
data object SubmitClicked : PostEditorIntent // Phase 2C (Reply / Quote) + Phase 2D (Edit)
data object ErrorDismissed : PostEditorIntent // (Phase 2C)
}
// Effets one-shot bypassant le state (jamais rejoués sur recomposition)
sealed interface PostEditorEffect {
data class SubmitSucceeded(val targetPage: Int?) : PostEditorEffect
}
Statut Phase 2C+2D+2E — Reply + Quote + Edit post + Edit FP + Create topic MVP livrés : deux ViewModels coexistent —
PostEditorViewModel(post-level : Reply viaReplyRepository, Edit post viaEditPostRepository) etTopicFormViewModel(topic-level : Edit FP + Create viaTopicFormRepository). Côté Create (#149) :initroute surloadNewTopicFormIfPossible(),canSubmitmode-aware,subjectHydratedFromServer+draftHydratedFromServerinitialisés àtruedès l’init (rien à hydrater côté serveur — l’utilisateur écrit du neuf) ; effet dédiéTopicFormEffect.NewTopicCreated(cat, subcat, newTopicId?, newNumreponse?). Le<select name=subcat>du form Create n’a aucune optionselected(parserparseNewTopicaccepte ce cas), l’utilisateur choisit dans le dropdown ; sirequest.subcatvient d’une chip catégorie, on présélectionne ce subcat dans le state. Tous trois repositories partagentHfrClient, le parser de réponse (ReplySubmitResponseParser), et la sémantique d’erreurs (ReplyFailureReason). Côté contrat : reply/quote POSTentbddpost.php, edit post et edit FP POSTentbdd.php;TopicFormScreenajoute en plus un champsujetmodifiable et exposera la sous-catégorie (re-catégorisation autorisée). Anti-clobber par champ : côté post-leveldraftHydratedFromForm/optionsHydratedFromForm; côté topic-levelsubjectHydratedFromServer/draftHydratedFromServer/optionsHydratedFromForm(hydratation indépendante par champ pour qu’un fetch lent puisse compléter ce que l’utilisateur n’a pas touché). Ces flags empêchent aussi un refetch silencieux surInvalidHashCheckd’écraser le travail utilisateur. Sur succès,SubmitSucceeded(targetPage, scrollTo?)— la navigation pop l’éditeur et recharge la page topic ; pour edit post et edit FP,scrollTo = numreponsecible le post édité. Sondage : Phase 2D #148 préserve les champs verbatim sans muter (édition active reportée à une future fixture). Création de topic (#149 Phase 2E) est livrée — voir le paragraphe au-dessus.Les deux écrans partagent leurs capacités via composables
:core:ui(BbcodeTextField,BbcodeToolbar,BbcodePreview, et plus tardPollEditor,CatSubcatPicker).BbcodePreviewreçoit unPostContentdéjà parsé — il ne parse pas lui-même, ce qui garde:core:uilibre de toute logique métier. Le parsing BBCode reste une responsabilité:core:parser(parsePostContentFromBbcode) exposée aux features via une interfaceBbcodePreviewParser(:core:domain) injectée par Hilt, afin de préserver la frontière:feature:*→:core:domain+:core:ui(les ViewModels appellent le use case et passent lePostContentrésultant àBbcodePreview). Pas de duplication, juste deux contrats de formulaire distincts. Rationale : l’endpoint HFR n’est pas une bonne frontière UI (ReplyetNewTopicpassent tous deux parbddpost.phpmais leurs formulaires diffèrent ;EditFirstPostetNewTopicpartagent presque toute la structure malgré des endpoints différents). La frontière utile est post-level vs topic-level.
Phase 2B-B (#144) — polish toolbar :
BbcodeActionest devenu unsealed interface(au lieu d’enum class) pour porterColor(colorHex). La toolbar expose désormais une palette couleur Material 3 (chip +DropdownMenu5 swatches). Contrat HFR :[#RRGGBB]…[/#RRGGBB], la balise fermante reprend le hex — pas de[/color]. Preview locale toujours synchrone et non bloquante ; preview serveurapercu.phpreportée tant qu’aucune divergence HFR n’apparaît. Listes BBCode ([list]/[*]) restent hors AST mais survivent en texte brut (BbcodeContentParserTest::full list block survives).
Écran Messages
data class MessagesUiState(
val mode: Mode = Mode.Loading,
val page: Int = 1,
val totalPages: Int = 1,
val isRefreshing: Boolean = false,
) {
sealed interface Mode {
data object RequiresLogin : Mode
data object Loading : Mode
data class Content(val conversations: List<PrivateMessageSummary>) : Mode
// #316 : aucun message brut — un IOException/SessionExpiredException peut embarquer
// l'URL `forum2.php?cat=prive&post=<id>` et fuiter l'identifiant de conversation.
data object Error : Mode
}
}
sealed interface MessagesIntent {
data object Refresh : MessagesIntent
data class OpenThread(val threadId: Int) : MessagesIntent
data object NewMP : MessagesIntent
data object NewMultiMP : MessagesIntent
}
Le MVP Phase 3 #298 implémente uniquement la lecture des MPs classiques : liste PrivateMessageSummary + conversation PrivateMessageThread. Les onglets MultiMP, réponse, quote et création restent des intents de phase suivante.
Convention
Chaque feature suit la même structure de fichiers (source set : src/main/kotlin/, cf. contributing.md pour les règles de nommage détaillées) :
feature/topic/src/main/kotlin/fr/forumhfr/redface2/feature/topic/
├── TopicScreen.kt // @Composable, collecte state + effects
├── TopicContent.kt // @Composable stateless, previewable (si extrait)
├── TopicViewModel.kt // MVI ViewModel (Hilt-injected via @HiltViewModel)
├── TopicUiState.kt // État UI + Intents (consolidés tant que court)
└── TopicRequest.kt // Paramètre d'entrée du screen (DTO dérivé de TopicRoute)
feature/topic/src/test/kotlin/fr/forumhfr/redface2/feature/topic/
└── TopicViewModelTest.kt // JUnit 4 + Turbine, fixture-driven
La NavKey (TopicRoute) ne vit pas dans le module feature : elle est déclarée côté :app dans app/src/main/kotlin/.../navigation/RedfaceNavigation.kt sous le sealed interface RedfaceNavKey. C’est la convention canonique pour Redface 2 — les routes @Serializable sont centralisées dans :app pour éviter les dépendances circulaires entre features. Détails dans contributing.md.
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.