Navigation

Écrans, flows, deep linking et bottom navigation.


Bottom Navigation

L’application utilise une barre de navigation en bas avec 4 onglets principaux + les réglages accessibles depuis chaque écran.

┌───────────┬───────────┬───────────┬───────────┐
│  Drapeaux │  Forum    │  Recherche│  Messages  │
│  (accueil)│           │           │            │
└───────────┴───────────┴───────────┴────────────┘

Drapeaux est l’écran d’accueil. C’est le point d’entrée principal — la plupart des utilisateurs HFR ouvrent l’app pour vérifier “quoi de neuf sur mes topics suivis”.


graph TB
    LOGIN[Auth / Login] --> HOME

    subgraph HOME["Bottom Navigation"]
        FLAGS["Drapeaux (accueil)"]
        FORUM[Forum]
        SEARCH[Recherche]
        MSGS[Messages]
    end

    FLAGS -->|"onglets: cyan / lu / favoris / super"| FLAGS
    FLAGS -->|"groupé par catégorie (#179)"| FLAGS
    FLAGS --> TOPIC

    FORUM --> CATS[Catégories]
    CATS --> SUBCATS[Sous-catégories]
    SUBCATS --> TOPICLIST[Liste de topics]
    TOPICLIST --> TOPIC
    TOPICLIST --> NEWTOPIC["Créer un topic"]

    SEARCH --> RESULTS[Résultats]
    RESULTS --> TOPIC

    MSGS --> TABMP["MPs classiques"]
    MSGS --> TABMULTI["MultiMPs (vue drapeaux)"]
    TABMP --> CONV[Conversation]
    TABMP --> NEWMP["Nouveau MP"]
    TABMULTI --> CONVMULTI["Conversation groupe"]
    TABMULTI --> NEWMULTI["Nouveau MultiMP"]
    CONV --> REPLYMP[Reply MP]
    CONVMULTI --> REPLYMULTI[Reply MultiMP]
    CONVMULTI --> QUOTEMULTI["Quote → Reply"]

    TOPIC --> REPLY[Reply]
    TOPIC --> EDIT["Edit post"]
    TOPIC --> EDITFP["Edit FP (sujet, contenu, sondage)"]
    TOPIC --> QUOTE["Quote → Reply"]
    TOPIC --> IMAGE[ImageViewer fullscreen]

    NEWTOPIC --> TOPIC

    style FLAGS fill:#00bcd4,color:#fff
    style FORUM fill:#4caf50,color:#fff
    style SEARCH fill:#ff9800,color:#fff
    style MSGS fill:#9c27b0,color:#fff
    style TOPIC fill:#e74c3c,color:#fff

Lecture du graphe : ce diagramme décrit le flow utilisateur, pas le découpage en NavKey. Les routes typées réelles sont FlagsListRoute, ForumRoute, CategoryRoute, TopicRoute, SearchRoute, MessagesRoute, PrivateMessageThreadRoute, PostEditorRoute, TopicFormRoute, ProfileFullRoute (Phase 2 finish #208) (cf. § Implémentation ci-dessous). Plusieurs nœuds du graphe sont des states internes au screen plutôt que des routes distinctes : TABMP / TABMULTI représentent les surfaces MP classique / MultiMP (seul le MP classique read-only est livré dans le MVP #298) ; CATS / SUBCATS / TOPICLIST sont couverts par la même CategoryRoute(cat, subcat?, page). Le mapping flow → routes typées est explicite dans le code de entryProvider plus bas.


Écrans en détail

Drapeaux (accueil)

L’écran le plus important de l’app. Affiche les topics suivis par l’utilisateur.

Onglets (FlagTab, un FlagType chacun sauf Super) :

  • Mes sujets (cyan) : topics où l’utilisateur a participé. Re-tap de l’onglet déjà sélectionné → toggle « +lus » (afficher/masquer les cyans déjà lus, #154).
  • Lu (rouge) : topics lus uniquement (drapeau de lecture sans participation).
  • Favoris : topics marqués d’une étoile jaune.
  • Super : placeholder « super favoris » (pas de backend, pas de fetch).

Regroupement par catégorie (#179, vue par défaut) :

  • À l’intérieur de chaque onglet réel, les topics sont groupés par catégorie, dans l’ordre canonique du forum (cf. ForumRepository.observeCategories() ; ordre de secours en dur si le catalogue n’est pas encore chargé). C’est la parité avec la vue web « Vos sujets ».
  • Chaque catégorie est une bande séparatrice (stickyHeader). Par défaut, les catégories vides sont conservées (parité web) avec un placeholder par onglet (« Aucun nouveau message » pour cyan, « Aucun sujet dans cette catégorie » sinon).
  • Le regroupement est purement client-side : group-by sur Flag.cat de la liste plate déjà chargée, aucun fetch authentifié supplémentaire (invariant prefetch-non-auth). Une catégorie absente du catalogue n’est jamais filtrée : elle tombe en section « inconnue » en fin de liste (anti-régression #251).

Préférences d’affichage (#179, persistées via UserPreferencesRepository / DataStore) :

  • Grouper par catégorie (défaut : activé) : désactivé, l’écran rend la liste à plat héritée (tous les drapeaux d’un coup, ordre dernière réponse, sans bandes de catégorie). Permet de conserver la lecture à plat des drapeaux.
  • Masquer les catégories sans message non lu (défaut : désactivé = parité web) : en vue groupée, cache les catégories qui n’ont aucun drapeau non lu. Le toggle cyan « +lus » prime sur ce filtre : afficher les sujets participés déjà lus garde leurs catégories visibles (sinon les deux réglages se contrediraient). Sans effet en vue à plat. Si le filtre vide toutes les sections, un placeholder « Aucune catégorie avec un message non lu » est affiché (corps jamais blanc, ancre pull-to-refresh préservée #229).

Réglages par type de drapeau (#309) : un master « Réglages différents par onglet » (défaut : désactivé) contrôle la portée des deux préférences ci-dessus :

  • désactivé : un réglage global unique partagé par tous les onglets ;
  • activé : chaque type de drapeau (cyan / rouge / favoris) garde ses propres valeurs, avec repli sur la valeur globale toggle par toggle (UserPreferencesRepository.observeFlagsViewSettings(type) résout global vs per-type). Les clés per-type sont sticky : désactiver le master ne les efface pas, le réactiver restaure le réglage par onglet précédent.

Deux surfaces de réglage (miroir) :

  • un bottom sheet M3 (ModalBottomSheet, « Affichage ») ouvert depuis l’en-tête de l’écran Drapeaux — masqué sur l’onglet Super (placeholder) et en anonyme ; il édite la portée courante (globale, ou l’onglet sélectionné quand le master est activé) et affiche un libellé de portée explicite ;
  • le miroir dans Réglages > Drapeaux (master « Réglages différents par onglet » + les deux toggles globaux qui servent de valeurs par défaut/repli).

Actions sur un topic :

  • Tap → ouvrir le topic à la dernière position non lue
  • Swipe (end-to-start) → ouvre le dialog de confirmation de retrait du drapeau (#99). Le retrait n’est pas annulable dans l’app (pas d’undo) ; la ligne revient en place tant que l’utilisateur n’a pas confirmé.

Profil utilisateur (Phase 2 finish #208)

Accessible depuis la lecture topic via un tap sur l’avatar ou le pseudo d’un post (quand Post.profileId != null).

Flow :

  1. TopicScreen émet onOpenProfile(userId, pseudo, avatarUrl) — callback sans dépendance sur :feature:profile.
  2. :app (RedfaceApp) ouvre une ModalBottomSheet (ProfilePreviewSheet) avec : avatar carré/arrondi, pseudo, localisation, date d’inscription, nombre de posts, bouton « Voir le profil complet ».
  3. Si le chargement échoue, la sheet reste lisible avec le pseudo/avatar hint + message d’erreur.
  4. Bouton « Voir le profil complet » navigue vers ProfileFullRoute(userId, pseudo, avatarUrl?) sur le back stack de l’onglet d’origine (celui depuis lequel la sheet a été ouverte), même si l’utilisateur change d’onglet pendant que la sheet est visible.
  5. ProfileFullRoute affiche la page complète avec tous les champs disponibles.

Routes :

@Serializable data class ProfileFullRoute(
    val userId: Int,        // clé canonique — jamais null
    val pseudo: String,     // hint d'affichage avant chargement
    val avatarUrl: String? = null, // hint d'affichage avant chargement
) : RedfaceNavKey

Contrainte de frontière : :feature:topic ne dépend pas de :feature:profile. Le callback onOpenProfile est la seule surface d’interaction. :app possède la ModalBottomSheet et la route complète.

Limites connues : le bouton « Derniers messages » dans la page complète est désactivé (marqué « à venir »). Aucune route stable vers les posts d’un utilisateur n’existe en Phase 2. La route sera activée dans une future PR quand la recherche par auteur sera disponible.

Topic (lecture)

L’écran central de l’app. Affiche les posts d’un topic avec pagination.

Navigation dans le topic :

  • Scroll vertical pour lire les posts
  • Boutons page précédente / suivante
  • Swipe horizontal gauche/droite pour changer de page (#282) — geste « drag-follow » (la page suit le doigt, résistance amortie aux bords, retour haptique à l’armement et au commit, edge-glow discret). Implémenté par Modifier.topicPageSwipe (feature/topic/.../TopicSwipe.kt, helpers purs testés) ; il appelle le même callback onOpenPage(targetPage) que les boutons de pager — la navigation est donc route-driven (remplace la TopicRoute courante), et la transition Topic→Topic est rendue instantanée (transitionSpec dédié, cf. RedfaceApp) pour supprimer la fenêtre morte du cross-fade. Le geste est gaté tant que l’entrée nav n’est pas RESUMED (évite un double-commit pendant la transition) et ne déclenche jamais d’action destructive.
  • Saut direct à une page (champ numéro)
  • Saut au premier / dernier post
  • Indicateur de page courante / total
  • Restauration de la position de lecture par page (#307) — revenir sur une page déjà visitée (swipe, pager, FAB, back) ré-atterrit à la position de scroll quittée, pas en haut. Le changement de page étant route-driven (#282), l’entrée nav — et son LazyListState — est détruite à chaque page : :app garde un cache session (cat, post, page) → ancre hoisté dans RedfaceApp (jumeau du cache de titres, borné à 128 avec éviction des ancres les moins récemment sauvegardées), sauvegardé au onDispose de l’écran (uniquement après un premier Loaded, pour ne pas écraser une vraie position par le (0, 0) d’une page abandonnée en chargement) et rejoué une seule fois au premier Loaded du retour. Priorité stricte résolue par resolveTopicScrollRestoration (app/.../navigation/TopicScrollRestore.kt, résolveur pur testé) : scrollTo route > atterrissage post-submit (submitSignal, #200/#226) > ancre sauvée > haut de page — les effets ScrollToPost/ScrollToEndOfPage restent seuls propriétaires de leurs atterrissages. Cf. TopicScrollAnchor (feature/topic/.../TopicScrollAnchor.kt).

Actions sur un post :

  • Quoter → ouvre l’éditeur avec la citation pré-remplie
  • Editer (si c’est notre post) → ouvre l’éditeur avec le contenu actuel
  • Editer le FP (si isFirstPostOwner) → éditeur spécial avec sujet + sondage
  • Copier le texte
  • Voir l’image en plein écran
  • Partager le lien du post

Forum (catégories)

Navigation hiérarchique dans le forum.

Catégories
  └── Hardware
       ├── HFR
       ├── Overclocking
       └── ...
  └── Programmation
       ├── C/C++
       ├── Java
       └── ...

Chaque catégorie affiche le nombre de topics et l’activité récente.

Création de topic

Formulaire complet :

  • Catégorie : sélecteur hiérarchique
  • Sous-catégorie : dépend de la catégorie choisie
  • Sujet : titre du topic
  • Contenu : éditeur BBCode avec toolbar
  • Sondage (optionnel) : question + options + choix multiple oui/non
  • Preview : avant-première du rendu du BBCode

Issue #198 — chaque écran principal (Drapeaux, Forum, Recherche, Messages) accepte un slot topBarActions: @Composable (() -> Unit)? = null dans son header. Le navigation host (RedfaceApp dans app/.../RedfaceNavigation.kt) instancie une seule AppAccountViewModel partagée et y branche un composant RedfaceAccountMenu (vivant dans :core:ui/account/) qui surface :

  • état compte (« Anonyme » / « Connecté en tant que X » / « Compte en cours de vérification… » pendant le warmup DataStore) ;
  • action Se connecter ou Se déconnecter selon AuthState ;
  • Paramètres alpha, Diagnostics alpha, Signaler un contenu (mailto xat@azora.fr) ;
  • footer version v{name} (build {code}).

Le badge est un carré à coins arrondis (8dp), pas un cercle, cohérent avec RedfaceUserAvatar. L’anti-flicker auth est préservé : tant que authState == null, le badge montre plutôt que ? pour ne pas surfacer transitoirement un état « Anonyme ». La déconnexion (AppAccountViewModel.logout) vide d’abord FlagRepository.clearSessionCache() avant AuthRepository.logout() ; cet ordering est verrouillé par AppAccountViewModelTest côté :app.

Depuis le MVP Phase 3 (#298), l’onglet Messages affiche la liste des MP classiques et ouvre une conversation en lecture seule. L’écran observe l’état d’authentification : en anonyme ou après déconnexion, les données privées déjà chargées sont purgées et remplacées par un état « connexion requise ».

Messages

Deux onglets :

MPs classiques :

  • Inbox : liste des conversations 1-to-1, triées par date (forum1.php?cat=prive)
  • Lecture d’une conversation : forum2.php?cat=prive&post={threadId}&page={page}
  • Chaque MP affiche : sujet, correspondant, date, lu/non-lu
  • Nouveau MP, réponse et quote MP : à faire en Phase 3 suivante

MultiMPs :

  • Vue style drapeaux : fils de groupe triés par dernier message
  • État lu/non-lu géré via MPStorage (données synchronisées depuis un MP HFR dédié, cachées en Room)
  • Chaque MultiMP se comporte comme un topic : pagination, quote, reply
  • Nouveau MultiMP : destinataires (2+) + sujet + contenu

Recherche

  • Recherche dans les topics (titre) et dans les posts (contenu)
  • Filtres : catégorie, auteur, date
  • Résultats avec preview du contexte

Deep Linking

Les URLs HFR doivent ouvrir directement le bon écran dans l’app.

Pattern URL Écran cible Statut
forum.hardware.fr/forum1.php?cat=X&post=Y&page=Z Topic page Z Phase 1
forum.hardware.fr/forum1.php?cat=X&post=Y Topic page 1 Phase 1
forum.hardware.fr/forum2.php?config=hfr.inc&cat=X&subcat=Y Liste topics Phase 1
forum.hardware.fr/forum1f.php Drapeaux Phase 1
forum.hardware.fr/forum1.php?cat=X&post=Y#t12345 Post spécifique (traitement custom, voir ci-dessous) Phase 1
forum.hardware.fr/forum1.php?config=hfr.inc&cat=prive&page=Z Inbox MP page Z Phase 3 MVP — contrat capturé, deep link non câblé
forum.hardware.fr/forum2.php?config=hfr.inc&cat=prive&post=Y&page=Z Conversation MP Y page Z Phase 3 MVP — contrat capturé, deep link non câblé

Deep links MP : les contrats cat=prive ci-dessus sont confirmés par fixtures HFR réelles, mais parseHfrDeepLink ne route pas encore ces URLs vers MessagesRoute / PrivateMessageThreadRoute. Elles restent à câbler dans un suivi dédié.

Implémentation via Compose Navigation 3 (1.1.0+, stable depuis 08/04/2026). Les routes sont des types @Serializable qui implémentent un sealed interface marqueur RedfaceNavKey : NavKey :

// app/src/main/kotlin/.../navigation/RedfaceNavigation.kt
@Serializable sealed interface RedfaceNavKey : NavKey

@Serializable data object FlagsListRoute : RedfaceNavKey
@Serializable data object ForumRoute : RedfaceNavKey
@Serializable data object SearchRoute : RedfaceNavKey
@Serializable data object MessagesRoute : RedfaceNavKey
@Serializable data class PrivateMessageThreadRoute(
    val threadId: Int,                     // id `post` HFR de la conversation `cat=prive`
    val page: Int = 1,
) : RedfaceNavKey                         // route opaque : pas de sujet/correspondant privé
@Serializable data class CategoryRoute(
    val cat: Int,
    val subcat: Int? = null,
    val page: Int = 1,
) : RedfaceNavKey
@Serializable data class TopicRoute(
    val cat: Int,
    val post: Int,
    val page: Int = 1,
    val scrollTo: Int? = null,            // numreponse cible pour #t{numreponse}
    val submitSignal: Long? = null,       // Phase 2 (#200) — bumpé à System.currentTimeMillis() par le
                                          // navigation host quand l'éditeur pop après un submit réussi.
                                          // Invalide la route key, force la rebuild du ViewModel, et fait
                                          // appeler `refreshTopicPage` (skip cache) pour que le post
                                          // fraîchement publié soit visible. Reste null sur tous les autres
                                          // chemins (deep link, pagination, retour Flags/Forum).
    val postSubmitOverflowLanding: Boolean = false, // #226 — true seulement sur la route re-poussée
                                          // après qu'une réponse simple a débordé sur une nouvelle
                                          // dernière page. Couplé à un submitSignal frais (force-fetch,
                                          // jamais de cache stale) ; signale au ViewModel que c'est
                                          // l'atterrissage d'overflow : scroller en bas SANS re-rediriger
                                          // (anti-chase si un post concurrent pousse encore totalPages).
) : RedfaceNavKey
@Serializable data class PostEditorRoute(
    val mode: PostEditorMode,
    val cat: Int,
    val topicId: Int? = null,             // requis pour Reply (Phase 2C)
    val numreponse: Int? = null,          // requis à terme pour Edit
    val page: Int? = null,                // page topic en cours, requis Reply (#145)
    val subcat: Int? = null,              // sous-cat HFR de POST, requis Reply (#145). subcat=0 valide (cat sans sous-cat, #213). TopicScreen ne pousse PostEditorRoute que si topic.canReply (présence du formulaire bddpost)
    val quotedNumreponse: Int? = null,    // Phase 2C (#146) : null = reply simple ; non-null = quote (numreponse du post cité)
    val quoteRef: Int? = null,            // Phase 2C (#146/#227) : ref opaque parsé depuis le href quote quand disponible ; null accepté (HFR cite via `numrep`)
) : RedfaceNavKey

@Serializable data class TopicFormRoute(
    val mode: TopicFormMode,
    val cat: Int? = null,
    val subcat: Int? = null,
    val topicId: Int? = null,
    val page: Int? = null,                // Phase 2D (#148) — page topic (1 pour EditFirstPost)
    val numreponse: Int? = null,          // Phase 2D (#148) — numreponse du premier post
) : RedfaceNavKey

@Serializable enum class PostEditorMode { Reply, Edit }
@Serializable enum class TopicFormMode { New, EditFirstPost }

Chaque onglet de bottom nav a son propre back stack (rememberNavBackStack), partagé via une Map<TopLevelDestination, NavBackStack<NavKey>> côté RedfaceApp. Le rendu se fait via l’API stable NavDisplay(backStack, onBack, entryDecorators, entryProvider) — pas besoin du couple rememberDecoratedNavEntries + rememberSceneState pour le cas single-pane :

@Composable
private fun RedfaceNavHost(backStack: NavBackStack<NavKey>) {
    NavDisplay(
        backStack = backStack,
        onBack = {
            if (backStack.size > 1) {
                backStack.removeAt(backStack.lastIndex)
            }
        },
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator(),
        ),
        entryProvider = entryProvider {
            entry<FlagsListRoute> {
                FlagsRoute(
                    onOpenFlag = { flag -> /* ... */ },
                    onLoginRequested = { /* ... */ },
                    topBarActions = accountMenu,
                )
            }
            entry<ForumRoute> { ForumScreen(onOpenCategory = { /* ... */ }, topBarActions = accountMenu) }
            entry<SearchRoute> { SearchScreen(onOpenTopic = { /* ... */ }, topBarActions = accountMenu) }
            entry<MessagesRoute> {
                MessagesScreen(
                    readThreadIds = readPrivateMessageThreadIds,
                    onOpenThread = { threadId, isMultiRecipient ->
                        if (isMultiRecipient) {
                            multiRecipientThreadIds = multiRecipientThreadIds + threadId
                        }
                        backStack.add(PrivateMessageThreadRoute(threadId))
                    },
                    topBarActions = accountMenu,
                )
            }
            entry<PrivateMessageThreadRoute> { route ->
                PrivateMessageThreadScreen(
                    request = PrivateMessageThreadRequest(
                        threadId = route.threadId,
                        page = route.page,
                    ),
                    isMultiRecipientHint = route.threadId in multiRecipientThreadIds,
                    onLoaded = { onPrivateMessageThreadLoaded(route.threadId) },
                    onBack = { backStack.removeAt(backStack.lastIndex) },
                    topBarActions = accountMenu,
                )
            }
            entry<CategoryRoute> { route ->
                ForumCategoryScreen(
                    request = CategoryRequest(
                        cat = route.cat,
                        initialSubcat = route.subcat,
                        initialPage = route.page,
                    ),
                    onOpenTopic = { topic ->
                        backStack.add(
                            TopicRoute(
                                cat = topic.cat,
                                post = topic.topicId,
                                page = topic.lastReadPage ?: 1,
                                scrollTo = topic.lastPostReadId,
                            ),
                        )
                    },
                )
            }
            entry<TopicRoute> { route ->
                TopicScreen(
                    request = TopicRequest(route.cat, route.post, route.page, route.scrollTo),
                    // #213: TopicScreen exposes subcat from the parsed topic page; the
                    // reply button is disabled when `topic.canReply` is false (no bddpost
                    // reply form on the page: logged-out / prefetch anon row, locked topic,
                    // or a cached row from before the v7 Room migration). subcat=0 (cat
                    // without sub-category) is a valid, postable value.
                    onReply = { subcat, page ->
                        backStack.add(
                            PostEditorRoute(
                                PostEditorMode.Reply,
                                route.cat,
                                topicId = route.post,
                                page = page,
                                subcat = subcat,
                            ),
                        )
                    },
                    // Phase 2D (#147): edit uses `PostEditorMode.Edit` and routes
                    // through `EditPostRepository` (bdd.php) ; success refreshes
                    // the topic and scrolls to the edited post.
                    onEdit = { subcat, page, numreponse ->
                        backStack.add(
                            PostEditorRoute(
                                PostEditorMode.Edit,
                                route.cat,
                                topicId = route.post,
                                numreponse = numreponse,
                                page = page,
                                subcat = subcat,
                            ),
                        )
                    },
                    // Phase 2C (#146): quote shares the destination with reply ; the
                    // editor switches behavior based on `quotedNumreponse != null`.
                    onQuote = { subcat, page, quotedNumreponse, quoteRef ->
                        backStack.add(
                            PostEditorRoute(
                                PostEditorMode.Reply,
                                route.cat,
                                topicId = route.post,
                                page = page,
                                subcat = subcat,
                                quotedNumreponse = quotedNumreponse,
                                quoteRef = quoteRef,
                            ),
                        )
                    },
                    onOpenPage = { targetPage ->
                        backStack.removeAt(backStack.lastIndex)
                        backStack.add(route.copy(page = targetPage, scrollTo = null))
                    },
                )
            }
            entry<PostEditorRoute> { route ->
                PostEditorScreen(
                    request = PostEditorRequest(
                        route.mode,
                        route.cat,
                        route.topicId,
                        route.numreponse,
                        route.page,
                        route.subcat,
                    ),
                    // Phase 2C (#145): pop editor + replace topic entry to refresh the
                    // target page (defaults to the page the user replied from when HFR
                    // does not surface a different one in the meta refresh URL).
                    onSubmitSucceeded = { targetPage ->
                        backStack.removeAt(backStack.lastIndex)
                        val topicEntry = backStack.lastOrNull() as? TopicRoute
                        if (topicEntry != null) {
                            backStack.removeAt(backStack.lastIndex)
                            backStack.add(topicEntry.copy(page = targetPage ?: topicEntry.page, scrollTo = null))
                        }
                    },
                )
            }
            entry<TopicFormRoute> { route ->
                TopicFormScreen(
                    mode = route.mode,
                    cat = route.cat,
                    subcat = route.subcat,
                    topicId = route.topicId,
                )
            }
        },
    )
}

NavigationSuiteScaffold (Material 3 Adaptive) commute la currentDestination (état rememberSaveable) et passe le back stack actif à RedfaceNavHost. Les autres back stacks restent en mémoire — quand l’utilisateur revient sur l’onglet Forum, il retombe à l’écran où il l’a quitté.

Avantages Nav 3 vs Nav 2.x pour Redface 2 :

  • Le back stack est du state observable standard — facile à persister/restaurer, à inspecter pour debug, à manipuler dans des tests
  • Plusieurs back stacks indépendants (un par onglet) sans avoir à hiérarchiser un nav graph
  • Intégration directe avec ListDetailPaneScaffold (Material 3 Adaptive 1.2+) — la liste et le détail vivent dans le même back stack mais s’affichent en parallèle sur tablette
  • API stable simple : NavDisplay(backStack, onBack, entryDecorators, entryProvider { entry<…> }), pas de DSL graph à apprendre

Cas particulier : lien vers un post spécifique

Nav 3 (comme Nav 2.x) ne gère pas les fragments URI (#t{numreponse}) nativement : on parse l’URI dans RedfaceApp, on identifie l’onglet cible (drapeaux, forum, …) et on réinitialise le back stack de cet onglet pour que le bouton retour ramène à la racine de l’onglet plutôt qu’à un état antérieur arbitraire :

// app/.../navigation/RedfaceNavigation.kt — extrait
@Composable
fun RedfaceApp(intent: Intent?) {
    val flagsBackStack = rememberNavBackStack(FlagsListRoute)
    val forumBackStack = rememberNavBackStack(ForumRoute)
    val searchBackStack = rememberNavBackStack(SearchRoute)
    val messagesBackStack = rememberNavBackStack(MessagesRoute)
    var currentDestination by rememberSaveable { mutableStateOf(TopLevelDestination.Flags) }

    val backStacks = mapOf(
        TopLevelDestination.Flags to flagsBackStack,
        TopLevelDestination.Forum to forumBackStack,
        TopLevelDestination.Search to searchBackStack,
        TopLevelDestination.Messages to messagesBackStack,
    )

    LaunchedEffect(intent) {
        val parsed = intent?.data?.let(::parseHfrDeepLink) ?: return@LaunchedEffect
        currentDestination = parsed.destination
        resetStack(
            backStack = backStacks.getValue(parsed.destination),
            root = parsed.destination.rootRoute,
            route = parsed.route,
        )
    }
    // Pour la suite (NavigationSuiteScaffold avec les 4 onglets, Surface wrapper et
    // RedfaceNavHost(backStack = backStacks.getValue(currentDestination))), voir
    // app/src/main/kotlin/.../navigation/RedfaceNavigation.kt ligne 126-142.
}

private data class ParsedDeepLink(val destination: TopLevelDestination, val route: RedfaceNavKey)

private fun parseHfrDeepLink(uri: Uri): ParsedDeepLink? = when (uri.path) {
    "/forum1.php" -> {
        val cat = uri.getQueryParameter("cat")?.toIntOrNull() ?: return null
        val subcat = uri.getQueryParameter("subcat")?.toIntOrNull()
        val page = uri.getQueryParameter("page")?.toIntOrNull()?.coerceAtLeast(1) ?: 1
        ParsedDeepLink(
            destination = TopLevelDestination.Forum,
            route = CategoryRoute(cat = cat, subcat = subcat, page = page),
        )
    }
    "/forum2.php" -> {
        val cat = uri.getQueryParameter("cat")?.toIntOrNull() ?: return null
        val post = uri.getQueryParameter("post")?.toIntOrNull() ?: return null
        val page = uri.getQueryParameter("page")?.toIntOrNull() ?: 1
        val scrollTo = uri.fragment?.removePrefix("t")?.toIntOrNull()
        ParsedDeepLink(
            destination = TopLevelDestination.Flags,
            route = TopicRoute(cat = cat, post = post, page = page, scrollTo = scrollTo),
        )
    }
    "/forum1f.php" -> ParsedDeepLink(TopLevelDestination.Flags, FlagsListRoute)
    else -> null
}

private fun resetStack(
    backStack: NavBackStack<NavKey>,
    root: RedfaceNavKey,
    route: RedfaceNavKey,
) {
    backStack.clear()
    backStack.add(root)
    if (route != root) backStack.add(route)
}

Le TopicScreen reçoit le scrollTo (numreponse cible) via la TopicRoute et scroll jusqu’au bon post après chargement de la page. Un scrollTo non nul prime toujours sur la position de lecture sauvegardée (#307) — cf. § Topic (lecture).

Politique de back stack sur deep link : on réinitialise le back stack de l’onglet cible (resetStack) plutôt qu’on n’empile sur l’historique courant. Rationale : un deep link entrant doit poser un état de navigation prévisible — back ramène à la racine de l’onglet, pas à un mélange d’écrans visités avant le deep link. Cf. § Back Stack ci-dessous.

Predictive back

Nav 3 intègre PredictiveBackHandler via NavDisplay — aucun code custom requis pour les écrans standards. Seuls les écrans à interaction custom (ex : éditeur avec draft) ajoutent leur propre handler ; Phase 2B-A livre PostEditorScreen sans cette confirmation (pas encore de draft persistant) — l’exemple ci-dessous reste le pattern cible quand la persistance arrivera :

@Composable
fun PostEditorScreen(state: PostEditorState, onIntent: (PostEditorIntent) -> Unit) {
    var showDiscardDialog by remember { mutableStateOf(false) }

    PredictiveBackHandler(enabled = state.draft.text.isNotEmpty()) { progress ->
        progress.collect { /* animation personnalisée si besoin */ }
        showDiscardDialog = true  // à la fin, on demande confirmation
    }

    // ... rest of the screen
}

Manifest requis : android:enableOnBackInvokedCallback="true" sur <application>.

Multi-pane adaptatif (tablette, foldables)

Statut Phase 5+ — multi-pane n’est pas livré en Phase 1. Dans le snippet ci-dessous :

  • le pattern de composition (NavDisplay + ListDetailPaneScaffold sur le même back stack, switch WindowSizeClass) est illustratif — c’est ce qui sera implémenté Phase 5+ ;
  • les signatures de screens appelées (FlagsRoute(onOpenFlag, onLoginRequested, topBarActions), MessagesScreen(onOpenThread: (threadId, isMultiRecipient) -> Unit, readThreadIds, topBarActions), PrivateMessageThreadScreen(request, isMultiRecipientHint, onLoaded, onBack, topBarActions), SearchScreen(onOpenTopic, topBarActions), ForumScreen(onOpenCategory, topBarActions), TopicScreen(request: TopicRequest, onReply: (subcat, page) -> Unit, onQuote: (subcat, page, quotedNumreponse, quoteRef) -> Unit, onEdit: (subcat, page, numreponse) -> Unit, onEditFirstPost: (subcat, page, numreponse) -> Unit, onOpenPage), PostEditorScreen(request: PostEditorRequest, onSubmitSucceeded: (targetPage?, scrollTo?) -> Unit), TopicFormScreen(request: TopicFormRequest, onSubmitSucceeded: (targetPage?, scrollTo?) -> Unit)) sont les signatures réelles livrées dans le repo, abrégées à leurs params structurants — les slots additionnels (onBack, onTitleLoaded, onOpenProfile, restoreScrollAnchor/onScrollAnchorSaved #307…) vivent dans les fichiers cités (cf. feature/topic/.../TopicScreen.kt, feature/flags/.../FlagsRoute.kt, feature/messages/.../MessagesScreen.kt, feature/search/.../SearchScreen.kt, feature/editor/.../PostEditorScreen.kt, feature/editor/.../TopicFormScreen.kt). Le slot topBarActions: @Composable (() -> Unit)? = null carrie le menu compte global depuis #198 — cf. § « Menu compte global ».

Le call-site onOpenFlag = { flag -> backStack.add(TopicRoute(flag.cat, flag.topicId, flag.lastReadPage, scrollTo = ...)) } passe désormais le topic concerné — Phase 1B.4 a remplacé le placeholder mock par la liste réelle des drapeaux.

@Composable
fun AdaptiveNavHost(backStack: NavBackStack<NavKey>) {
    val isExpanded = currentWindowAdaptiveInfo().windowSizeClass.windowWidthSizeClass !=
        WindowWidthSizeClass.COMPACT

    if (isExpanded) {
        ListDetailPaneScaffold(
            listPane = {
                FlagsRoute(
                    onOpenFlag = { flag ->
                        backStack.add(
                            TopicRoute(
                                cat = flag.cat,
                                post = flag.topicId,
                                page = flag.lastReadPage,
                                scrollTo = flag.lastPostReadId
                                    ?.takeIf { it in 1L..Int.MAX_VALUE.toLong() }
                                    ?.toInt(),
                            ),
                        )
                    },
                    onLoginRequested = { backStack.add(LoginRoute) },
                )
            },
            detailPane = {
                when (val current = backStack.lastOrNull()) {
                    is TopicRoute -> TopicScreen(
                        request = TopicRequest(
                            cat = current.cat,
                            post = current.post,
                            page = current.page,
                            scrollTo = current.scrollTo,
                        ),
                        onReply = { subcat, page ->
                            backStack.add(PostEditorRoute(PostEditorMode.Reply, current.cat, topicId = current.post, page = page, subcat = subcat))
                        },
                        onOpenPage = { targetPage ->
                            backStack.removeAt(backStack.lastIndex)
                            backStack.add(current.copy(page = targetPage, scrollTo = null))
                        },
                    )
                    is PostEditorRoute -> PostEditorScreen(
                        request = PostEditorRequest(current.mode, current.cat, current.topicId, current.numreponse, current.page, current.subcat),
                        onSubmitSucceeded = { /* multi-pane refresh handled by parent observer */ },
                    )
                    is TopicFormRoute -> TopicFormScreen(
                        mode = current.mode,
                        cat = current.cat,
                        subcat = current.subcat,
                        topicId = current.topicId,
                    )
                    else -> Text("Select a topic")
                }
            },
        )
    } else {
        RedfaceNavHost(backStack = backStack)
    }
}

Phase 1B.4 a livré FlagsRoute (dans :feature:flags) avec le vrai modèle Flag ; en Phase 1D-1 le scroll anchor est passé de firstUnreadPostId à lastPostReadId (REST last_post_read_id) : backStack.add(TopicRoute(flag.cat, flag.topicId, flag.lastReadPage, scrollTo = flag.lastPostReadId?.takeIf { it in 1L..Int.MAX_VALUE.toLong() }?.toInt())). Phase 1C-A a ensuite remplacé les placeholders Forum/Category par ForumScreen + ForumCategoryScreen alimentés par ForumRepository REST. Le polish pré-Phase 2 (#154) a retiré les constantes DEMO_TOPIC_* et leurs callbacks : SearchScreen() n’expose plus de bouton de navigation. La Phase 2 finish (#198) a hoisté les actions compte (login/logout) et outils alpha (Diagnostics, signalement, version) vers le menu compte global (RedfaceAccountMenu dans :core:ui, AppAccountViewModel dans :app/navigation/) injecté par topBarActions dans chaque écran principal. Le MVP Phase 3 #298 remplace ensuite le placeholder MessagesScreen par la liste MP classique + PrivateMessageThreadRoute en lecture seule.


Back Stack

Nav 3 expose le back stack comme un NavBackStack<NavKey> observable, puis NavDisplay le rend directement entry par entry. Règles Redface 2 :

  • Bottom nav : chaque onglet conserve son propre back stack (un rememberNavBackStack(...) par onglet, le NavDisplay actif reçoit celui de la currentDestination). Quand l’utilisateur change d’onglet, le back stack précédent reste en mémoire et reprend où il en était.
  • Retour depuis un topic : retour à la liste (drapeaux, forum, recherche) à la même position de scroll — l’entrée précédente est conservée dans la liste tant qu’elle est dans le back stack.
  • Retour depuis reply/edit : retour au topic à la même page.
  • Deep link : on identifie l’onglet cible et on réinitialise son back stack via resetStack(root, route) (cf. § Cas particulier : lien vers un post spécifique). Conséquence prévisible : back depuis le deep link ramène à la racine de l’onglet (drapeaux, forum, …), pas à un état pré-deep-link arbitraire.
graph LR
    A["Drapeaux"] --> B["Topic (page 3)"]
    B --> C["Quote → Reply"]
    C -->|"Back"| B
    B -->|"Back"| A
    A -->|"Back"| EXIT["Quitter l'app"]

Haut de page

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

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