mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 07:37:38 +00:00
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
618 lines
16 KiB
Markdown
618 lines
16 KiB
Markdown
# 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
|
|
|
|
```kotlin
|
|
// 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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Deep Link Handling
|
|
|
|
### Intent Processing
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```xml
|
|
<!-- 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
|
|
|
|
```kotlin
|
|
@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
|
|
|
|
```kotlin
|
|
@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`
|