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”.
Navigation Graph
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 sontFlagsListRoute,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/TABMULTIreprésentent les surfaces MP classique / MultiMP (seul le MP classique read-only est livré dans le MVP #298) ;CATS/SUBCATS/TOPICLISTsont couverts par la mêmeCategoryRoute(cat, subcat?, page). Le mapping flow → routes typées est explicite dans le code deentryProviderplus 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.catde 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 :
TopicScreenémetonOpenProfile(userId, pseudo, avatarUrl)— callback sans dépendance sur:feature:profile.:app(RedfaceApp) ouvre uneModalBottomSheet(ProfilePreviewSheet) avec : avatar carré/arrondi, pseudo, localisation, date d’inscription, nombre de posts, bouton « Voir le profil complet ».- Si le chargement échoue, la sheet reste lisible avec le pseudo/avatar hint + message d’erreur.
- 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. ProfileFullRouteaffiche 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 callbackonOpenPage(targetPage)que les boutons de pager — la navigation est donc route-driven (remplace laTopicRoutecourante), et la transition Topic→Topic est rendue instantanée (transitionSpecdédié, cf.RedfaceApp) pour supprimer la fenêtre morte du cross-fade. Le geste est gaté tant que l’entrée nav n’est pasRESUMED(é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 ::appgarde un cache session(cat, post, page) → ancrehoisté dansRedfaceApp(jumeau du cache de titres, borné à 128 avec éviction des ancres les moins récemment sauvegardées), sauvegardé auonDisposede l’écran (uniquement après un premierLoaded, pour ne pas écraser une vraie position par le(0, 0)d’une page abandonnée en chargement) et rejoué une seule fois au premierLoadeddu retour. Priorité stricte résolue parresolveTopicScrollRestoration(app/.../navigation/TopicScrollRestore.kt, résolveur pur testé) :scrollToroute > atterrissage post-submit (submitSignal, #200/#226) > ancre sauvée > haut de page — les effetsScrollToPost/ScrollToEndOfPagerestent 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
Menu compte global
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 connecterouSe déconnecterselonAuthState; Paramètres alpha,Diagnostics alpha,Signaler un contenu(mailtoxat@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=priveci-dessus sont confirmés par fixtures HFR réelles, maisparseHfrDeepLinkne route pas encore ces URLs versMessagesRoute/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+ListDetailPaneScaffoldsur le même back stack, switchWindowSizeClass) 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 slottopBarActions: @Composable (() -> Unit)? = nullcarrie 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, leNavDisplayactif reçoit celui de lacurrentDestination). 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"]