Files
amethyst/.claude/skills/android-expert/references/android-navigation.md
Claude f94fbe25b1 refactor: put the moved UI in the documented commons layout and fix the audit findings
A review of this branch against commons/ARCHITECTURE.md found the first
moves had kept the app's layer-first paths (commons.ui.screen.loggedIn.<x>)
and put headless code under ui.*. Everything now follows the documented
taxonomy:

  - Feature UI lives in <feature>/ui, named after its quartz package:
    nip51Lists/ui (bookmark groups, people lists, follow packs, interest
    sets), nip52Calendar/ui, nip34Git/ui, nip64Chess/ui, nip23LongContent/ui,
    nip28PublicChat/ui, nip29RelayGroups/ui, nip17Dm/ui, nip56Reports/ui,
    nip72ModCommunities/ui, nipC0CodeSnippets/ui, nipCCGeocaching/ui,
    nipACWebRtcCalls/ui, and concern names where quartz has no NIP (chats,
    marmot, concord, ephemChat, buzz, birdstar, music, cashu, onchain,
    browser, napplet, profile, mediaServers, relays, qrcode, scheduledposts,
    account/ui/login|signup). commons.ui.screen is gone.
  - Nothing under ui.* remains in the commons module. The Route catalog and
    BookmarkType go to model/navigation and model/nip51Lists; the headless
    composer state (AudienceSelection, SplitBuilder, IZapRaiser) to
    model/composer; classifyScannedPayload to qrcode; the scheduled-post
    parsers split out of ScheduledPostMedia into commons scheduledposts,
    leaving only MediaThumbnail in commonsUI. The CLI can now reach them.
  - ARCHITECTURE.md documents ui/feeds and ui/navigation as cross-cutting
    areas and lists the new feature packages.

Duplicates removed:
  - AddButton/RemoveButton existed twice in commonsUI; the unused
    ui/components pair (hard-coded English) is replaced by the localized
    one, which now lives in ui/components/ActionButtons.kt.
  - desktop's own UserDisplayNameLayout was an unused copy of the shared
    one; deleted.
  - Colors.kt merged into Color.kt: empty section headers dropped,
    LightPurple (= Purple200, unused) removed, DefaultPrimary (= Primary80)
    replaced at its one desktop call site.

isLight:
  - The fallback for non-Amethyst backgrounds (desktop's palette, audio
    rooms whose theme overrides the background) is now memoized for the
    last background seen, so those screens pay for luminance() once per
    background instead of on every themed-color read. The comment said
    "single reference comparison", which was no longer true; rewritten.
  - IsLightTest covers both Amethyst schemes across accents, desktop's
    #121212 / #F2F2F7 ramps, themed-room backgrounds and the memo.
  - Behaviour note: NestThemedScope rooms with a dark background used to
    count as light (anything not pure black did), so their gray text,
    placeholders and borders took the light-theme values. They now follow
    the room background's luminance.

Also:
  - Baseline profile: the entries for every class this branch moved
    (theme facades, the Theme.kt members that became AmethystColorScheme /
    MarkdownStyle, routes, the moved composables) are rewritten to the new
    class names so ART still AOT-compiles them. The profile has older stale
    entries from earlier migrations (datasource assemblers, LocalCache,
    okhttp) that need a real regeneration on a device.
  - Two inline fully-qualified Route references in the nests lobby use the
    import instead.
  - GitStatusPill's KDoc said the index read "stays native"; it no longer
    does.
  - The android-expert skill and its navigation reference point to the new
    Route/INav/theme locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168wY9t7i9NC5u3svyMxEz6
2026-09-23 19:33:50 +00:00

16 KiB

Android Navigation Patterns

Complete navigation implementation patterns for Amethyst Android app using Navigation Compose with type safety.

Type-Safe Routes (Navigation 2.8.0+)

Route Definitions

// Routes.kt - All 40+ routes in Amethyst
@Serializable
sealed class Route {
    // Bottom nav routes
    @Serializable object Home : Route()
    @Serializable object Messages : Route()
    @Serializable object Video : Route()
    @Serializable object Discover : Route()
    @Serializable object Notification : Route()

    // Content routes with parameters
    @Serializable data class Profile(val pubkey: String) : Route()
    @Serializable data class Note(val id: String) : Route()
    @Serializable data class Channel(val id: String) : Route()
    @Serializable data class Thread(
        val id: String,
        val replyTo: String? = null
    ) : Route()

    // New content routes
    @Serializable data class NewPost(
        val message: String? = null,
        val attachment: String? = null,
        val replyTo: String? = null
    ) : Route()

    // Settings
    @Serializable object Settings : Route()
    @Serializable object Security : Route()
    @Serializable object Relays : Route()

    // Search
    @Serializable data class Search(val query: String = "") : Route()

    // Media
    @Serializable data class Image(val url: String) : Route()
    @Serializable data class Video(val url: String) : Route()
}

NavHost Configuration

Basic Setup

@Composable
fun AppNavigation(
    navController: NavHostController,
    accountViewModel: AccountViewModel,
    drawerState: DrawerState
) {
    val scope = rememberCoroutineScope()
    val nav = remember {
        Nav(navController, drawerState, scope)
    }

    NavHost(
        navController = navController,
        startDestination = Route.Home,
        enterTransition = { fadeIn(animationSpec = tween(200)) },
        exitTransition = { fadeOut(animationSpec = tween(200)) },
        popEnterTransition = { fadeIn(animationSpec = tween(200)) },
        popExitTransition = { fadeOut(animationSpec = tween(200)) }
    ) {
        // Define routes
        composable<Route.Home> {
            HomeScreen(accountViewModel, nav)
        }

        composable<Route.Profile> { backStackEntry ->
            val profile = backStackEntry.toRoute<Route.Profile>()
            ProfileScreen(
                pubkey = profile.pubkey,
                accountViewModel = accountViewModel,
                nav = nav
            )
        }

        composable<Route.Note> { backStackEntry ->
            val note = backStackEntry.toRoute<Route.Note>()
            NoteScreen(
                noteId = note.id,
                accountViewModel = accountViewModel,
                nav = nav
            )
        }

        composable<Route.NewPost> { backStackEntry ->
            val newPost = backStackEntry.toRoute<Route.NewPost>()
            NewPostScreen(
                initialMessage = newPost.message,
                initialAttachment = newPost.attachment,
                replyTo = newPost.replyTo,
                accountViewModel = accountViewModel,
                onPost = { nav.popBack() }
            )
        }
    }
}

Custom Transitions

composable<Route.Profile>(
    enterTransition = {
        slideIntoContainer(
            AnimatedContentTransitionScope.SlideDirection.Start,
            animationSpec = tween(300)
        )
    },
    exitTransition = {
        slideOutOfContainer(
            AnimatedContentTransitionScope.SlideDirection.Start,
            animationSpec = tween(300)
        )
    },
    popEnterTransition = {
        slideIntoContainer(
            AnimatedContentTransitionScope.SlideDirection.End,
            animationSpec = tween(300)
        )
    },
    popExitTransition = {
        slideOutOfContainer(
            AnimatedContentTransitionScope.SlideDirection.End,
            animationSpec = tween(300)
        )
    }
) { backStackEntry ->
    val profile = backStackEntry.toRoute<Route.Profile>()
    ProfileScreen(profile.pubkey, accountViewModel, nav)
}

Navigation Manager

Nav Wrapper Class

class Nav(
    val controller: NavHostController,
    val drawerState: DrawerState,
    val scope: CoroutineScope
) {
    /**
     * Navigate to a route, closing drawer if open
     */
    fun nav(route: Route) {
        scope.launch {
            if (!controller.popBackStack(route, inclusive = false)) {
                controller.navigate(route) {
                    launchSingleTop = true
                }
            }
            drawerState.close()
        }
    }

    /**
     * Navigate with new stack (clear back stack to Home)
     */
    fun newStack(route: Route) {
        scope.launch {
            controller.navigate(route) {
                popUpTo(Route.Home) {
                    inclusive = false
                }
                launchSingleTop = true
            }
            drawerState.close()
        }
    }

    /**
     * Pop back stack
     */
    fun popBack() {
        controller.popBackStack()
    }

    /**
     * Pop up to specific route
     */
    inline fun <reified T : Route> popUpTo(inclusive: Boolean = false) {
        controller.popBackStack<T>(inclusive = inclusive)
    }

    /**
     * Get current route
     */
    fun currentRoute(): Route? {
        return controller.currentBackStackEntry?.toRoute<Route>()
    }
}

Bottom Navigation

Material3 NavigationBar

@Composable
fun AppBottomBar(
    currentRoute: Route?,
    nav: Nav
) {
    NavigationBar(
        containerColor = MaterialTheme.colorScheme.surface,
        contentColor = MaterialTheme.colorScheme.onSurface
    ) {
        BottomBarRoute.entries.forEach { item ->
            NavigationBarItem(
                selected = currentRoute?.let { it::class == item.route::class } ?: false,
                onClick = { nav.nav(item.route) },
                icon = {
                    Icon(
                        imageVector = if (currentRoute?.let { it::class == item.route::class } == true) {
                            item.selectedIcon
                        } else {
                            item.unselectedIcon
                        },
                        contentDescription = item.label
                    )
                },
                label = { Text(item.label) },
                alwaysShowLabel = false
            )
        }
    }
}

enum class BottomBarRoute(
    val route: Route,
    val selectedIcon: ImageVector,
    val unselectedIcon: ImageVector,
    val label: String
) {
    HOME(
        route = Route.Home,
        selectedIcon = Icons.Filled.Home,
        unselectedIcon = Icons.Outlined.Home,
        label = "Home"
    ),
    MESSAGES(
        route = Route.Messages,
        selectedIcon = Icons.Filled.Message,
        unselectedIcon = Icons.Outlined.Message,
        label = "Messages"
    ),
    VIDEOS(
        route = Route.Video,
        selectedIcon = Icons.Filled.VideoLibrary,
        unselectedIcon = Icons.Outlined.VideoLibrary,
        label = "Videos"
    ),
    DISCOVER(
        route = Route.Discover,
        selectedIcon = Icons.Filled.Explore,
        unselectedIcon = Icons.Outlined.Explore,
        label = "Discover"
    ),
    NOTIFICATIONS(
        route = Route.Notification,
        selectedIcon = Icons.Filled.Notifications,
        unselectedIcon = Icons.Outlined.Notifications,
        label = "Notifications"
    )
}

Observing Current Route

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val currentBackStackEntry by navController.currentBackStackEntryAsState()
    val currentRoute = currentBackStackEntry?.toRoute<Route>()

    Scaffold(
        topBar = {
            if (shouldShowTopBar(currentRoute)) {
                AppTopBar(currentRoute)
            }
        },
        bottomBar = {
            if (shouldShowBottomBar(currentRoute)) {
                AppBottomBar(currentRoute, nav)
            }
        }
    ) { paddingValues ->
        AppNavigation(
            navController = navController,
            modifier = Modifier.padding(paddingValues)
        )
    }
}

fun shouldShowBottomBar(route: Route?): Boolean {
    return when (route) {
        is Route.Home,
        is Route.Messages,
        is Route.Video,
        is Route.Discover,
        is Route.Notification -> true
        else -> false
    }
}

Navigation Drawer

Material3 ModalDrawerSheet

@Composable
fun AppDrawer(
    drawerState: DrawerState,
    nav: Nav,
    accountViewModel: AccountViewModel
) {
    val scope = rememberCoroutineScope()

    ModalDrawerSheet {
        // User profile header
        DrawerHeader(accountViewModel.account)

        HorizontalDivider()

        // Menu items
        NavigationDrawerItem(
            label = { Text("Home") },
            selected = false,
            onClick = { nav.nav(Route.Home) },
            icon = { Icon(Icons.Default.Home, "Home") }
        )

        NavigationDrawerItem(
            label = { Text("Profile") },
            selected = false,
            onClick = { nav.nav(Route.Profile(accountViewModel.account.pubkey)) },
            icon = { Icon(Icons.Default.Person, "Profile") }
        )

        NavigationDrawerItem(
            label = { Text("Settings") },
            selected = false,
            onClick = { nav.nav(Route.Settings) },
            icon = { Icon(Icons.Default.Settings, "Settings") }
        )

        HorizontalDivider()

        NavigationDrawerItem(
            label = { Text("Logout") },
            selected = false,
            onClick = {
                scope.launch {
                    accountViewModel.logout()
                    drawerState.close()
                }
            },
            icon = { Icon(Icons.Default.Logout, "Logout") }
        )
    }
}

Main Scaffold with Drawer

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    val drawerState = rememberDrawerState(DrawerValue.Closed)
    val scope = rememberCoroutineScope()
    val nav = remember { Nav(navController, drawerState, scope) }

    ModalNavigationDrawer(
        drawerState = drawerState,
        drawerContent = {
            AppDrawer(drawerState, nav, accountViewModel)
        }
    ) {
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("Amethyst") },
                    navigationIcon = {
                        IconButton(
                            onClick = { scope.launch { drawerState.open() } }
                        ) {
                            Icon(Icons.Default.Menu, "Menu")
                        }
                    }
                )
            },
            bottomBar = { AppBottomBar(currentRoute, nav) }
        ) { paddingValues ->
            AppNavigation(
                navController = navController,
                modifier = Modifier.padding(paddingValues)
            )
        }
    }
}

Intent Processing

@Composable
fun AppNavigation(
    navController: NavHostController,
    accountViewModel: AccountViewModel
) {
    val activity = LocalContext.current as? Activity

    // Handle incoming intents
    LaunchedEffect(activity?.intent) {
        activity?.intent?.let { intent ->
            handleIntent(intent, navController)
        }
    }

    NavHost(navController = navController) {
        // Routes...
    }
}

fun handleIntent(intent: Intent, navController: NavHostController) {
    when (intent.action) {
        Intent.ACTION_SEND -> {
            // Share text/image
            val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
            val sharedUri = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)

            navController.navigate(
                Route.NewPost(
                    message = sharedText,
                    attachment = sharedUri?.toString()
                )
            )
        }

        Intent.ACTION_VIEW -> {
            // Deep link
            intent.data?.let { uri ->
                when (uri.scheme) {
                    "nostr" -> handleNostrUri(uri, navController)
                    "https", "http" -> handleWebUri(uri, navController)
                }
            }
        }
    }
}

fun handleNostrUri(uri: Uri, navController: NavHostController) {
    val path = uri.pathSegments.firstOrNull() ?: return

    when {
        path.startsWith("npub") -> {
            navController.navigate(Route.Profile(path))
        }
        path.startsWith("note") -> {
            navController.navigate(Route.Note(path))
        }
        path.startsWith("nevent") -> {
            // Decode and navigate to event
            val eventId = decodeNevent(path)
            navController.navigate(Route.Note(eventId))
        }
    }
}

fun handleWebUri(uri: Uri, navController: NavHostController) {
    // Handle web-based deep links
    // https://njump.me/npub1...
    // https://primal.net/profile/npub1...
    when (uri.host) {
        "njump.me" -> {
            val id = uri.pathSegments.lastOrNull()
            if (id?.startsWith("npub") == true) {
                navController.navigate(Route.Profile(id))
            }
        }
        "primal.net" -> {
            // Parse primal.net URLs
        }
    }
}

AndroidManifest Intent Filters

<!-- MainActivity -->
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="nostr" />
</intent-filter>

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="njump.me" />
    <data android:scheme="https" android:host="primal.net" />
    <data android:scheme="https" android:host="iris.to" />
</intent-filter>

<intent-filter>
    <action android:name="android.intent.action.SEND" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:mimeType="text/plain" />
    <data android:mimeType="image/*" />
</intent-filter>

Nested Navigation

Tab Navigation Inside Screen

@Composable
fun ProfileScreen(
    pubkey: String,
    nav: Nav
) {
    val nestedNavController = rememberNavController()

    Column {
        ProfileHeader(pubkey)

        // Tab row
        TabRow(selectedTabIndex = currentTab) {
            Tab(selected = currentTab == 0, onClick = { /* Notes */ })
            Tab(selected = currentTab == 1, onClick = { /* Replies */ })
            Tab(selected = currentTab == 2, onClick = { /* Likes */ })
        }

        // Nested NavHost for tabs
        NavHost(
            navController = nestedNavController,
            startDestination = ProfileTab.Notes
        ) {
            composable<ProfileTab.Notes> {
                NotesTabContent(pubkey)
            }
            composable<ProfileTab.Replies> {
                RepliesTabContent(pubkey)
            }
            composable<ProfileTab.Likes> {
                LikesTabContent(pubkey)
            }
        }
    }
}

@Serializable
sealed class ProfileTab {
    @Serializable object Notes : ProfileTab()
    @Serializable object Replies : ProfileTab()
    @Serializable object Likes : ProfileTab()
}

Testing Navigation

Navigation Test Example

@Test
fun testNavigationToProfile() {
    val navController = TestNavHostController(
        ApplicationProvider.getApplicationContext()
    )

    composeTestRule.setContent {
        navController.navigatorProvider.addNavigator(
            ComposeNavigator()
        )
        AppNavigation(navController, accountViewModel)
    }

    // Navigate to profile
    composeTestRule.onNodeWithText("Profile").performClick()

    // Verify navigation
    val currentRoute = navController.currentBackStackEntry?.toRoute<Route>()
    assertTrue(currentRoute is Route.Profile)
}

File Locations

  • commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/navigation/Routes.kt (route catalog)
  • commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/navs/INav.kt
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteNavController.kt (NavHost helpers)
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/Nav.kt
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/AppBottomBar.kt
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt