Skip to content

Commit a3392a3

Browse files
Merge pull request #1124 from torlando-tech/feature/configurable-bottom-nav
User-configurable bottom navigation + NomadNet browsing polish
2 parents fc086dc + 28c65ef commit a3392a3

14 files changed

Lines changed: 741 additions & 40 deletions

File tree

app/src/main/java/network/columba/app/MainActivity.kt

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ import androidx.compose.runtime.LaunchedEffect
5050
import androidx.compose.runtime.MutableState
5151
import androidx.compose.runtime.collectAsState
5252
import androidx.compose.runtime.getValue
53-
import androidx.compose.runtime.mutableIntStateOf
5453
import androidx.compose.runtime.mutableStateOf
5554
import androidx.compose.runtime.remember
5655
import androidx.compose.runtime.setValue
@@ -91,6 +90,7 @@ import network.columba.app.navigation.navigateToAnsweredCall
9190
import network.columba.app.navigation.navigateToEntity
9291
import network.columba.app.navigation.navigateToIncomingCall
9392
import network.columba.app.navigation.shouldPresentIncomingCall
93+
import network.columba.app.navigation.NavTab
9494
import network.columba.app.notifications.CallNotificationHelper
9595
import network.columba.app.repository.InterfaceRepository
9696
import network.columba.app.repository.SettingsRepository
@@ -741,7 +741,6 @@ fun ColumbaNavigation(
741741
val context = LocalContext.current
742742
val lifecycleOwner = LocalLifecycleOwner.current
743743
val navController = rememberNavController()
744-
var selectedTab by remember { mutableIntStateOf(0) }
745744

746745
LaunchedEffect(detachedUsbDeviceEvents, navController) {
747746
detachedUsbDeviceEvents.collect { deviceId ->
@@ -926,7 +925,6 @@ fun ColumbaNavigation(
926925
}
927926
is PendingNavigation.AddContact -> {
928927
// Navigate to contacts tab and trigger add contact dialog
929-
selectedTab = 1 // Contacts tab
930928
navController.navigate(Screen.Contacts.route) {
931929
popUpTo(navController.graph.startDestinationId) {
932930
saveState = true
@@ -950,7 +948,6 @@ fun ColumbaNavigation(
950948
is PendingNavigation.SharedText -> {
951949
sharedTextViewModel.setText(navigation.text)
952950

953-
selectedTab = 0
954951
val poppedToChats = navController.popBackStack(Screen.Chats.route, inclusive = false)
955952
if (!poppedToChats) {
956953
navController.navigate(Screen.Chats.route) {
@@ -966,7 +963,6 @@ fun ColumbaNavigation(
966963
is PendingNavigation.SharedImage -> {
967964
sharedImageViewModel.setImages(navigation.uris)
968965

969-
selectedTab = 0
970966
val poppedToChats = navController.popBackStack(Screen.Chats.route, inclusive = false)
971967
if (!poppedToChats) {
972968
navController.navigate(Screen.Chats.route) {
@@ -1170,19 +1166,6 @@ fun ColumbaNavigation(
11701166
val navBackStackEntry by navController.currentBackStackEntryAsState()
11711167
val currentRoute = navBackStackEntry?.destination?.route
11721168

1173-
// Synchronize selectedTab with current route when navigating back
1174-
LaunchedEffect(currentRoute) {
1175-
Log.d("ColumbaNavigation", "📍 currentRoute changed to: $currentRoute")
1176-
selectedTab =
1177-
when (currentRoute) {
1178-
Screen.Chats.route -> 0
1179-
Screen.Contacts.route -> 1
1180-
Screen.Map.route -> 2
1181-
Screen.Settings.route -> 3
1182-
else -> selectedTab // Keep current selection for nested screens
1183-
}
1184-
}
1185-
11861169
// Observe call state for incoming calls and navigate to IncomingCallScreen.
11871170
// Composable functions can't @Inject, so the RnsTelephony seam singleton is
11881171
// reached through Hilt's RnsTelephonyEntryPoint. Replaces the A.9-era
@@ -1266,7 +1249,9 @@ fun ColumbaNavigation(
12661249
listOf(
12671250
"offline_map_download",
12681251
"messaging/",
1269-
"announce_detail/",
1252+
// announce_detail (Node Details) intentionally keeps the nav bar:
1253+
// it sits one tap from the tabs, and hiding the bar made returning
1254+
// from NomadNet flows feel jarring.
12701255
"message_detail/",
12711256
"theme_editor",
12721257
"rnode_wizard",
@@ -1277,20 +1262,15 @@ fun ColumbaNavigation(
12771262
"voice_call/",
12781263
"incoming_call/",
12791264
"interface_stats/",
1280-
"nomadnet_browser/",
12811265
)
12821266
val shouldShowBottomNav =
12831267
currentRoute != null &&
12841268
currentRoute !in hideBottomNavScreens &&
12851269
hideBottomNavPrefixes.none { currentRoute.startsWith(it) }
12861270

1287-
val screens =
1288-
listOf(
1289-
Screen.Chats,
1290-
Screen.Contacts,
1291-
Screen.Map,
1292-
Screen.Settings,
1293-
)
1271+
// User-configurable bottom bar tabs (Settings pinned last, max NavTab.MAX_TABS).
1272+
// The NomadNet tab is a normal tab: the bar stays visible while browsing pages.
1273+
val bottomNavTabs = settingsState.bottomNavTabs
12941274

12951275
// Double-back-to-exit state: first back press on a root tab shows a toast,
12961276
// second press within 2 seconds finishes the activity.
@@ -1345,14 +1325,32 @@ fun ColumbaNavigation(
13451325
bottomBar = {
13461326
if (shouldShowBottomNav) {
13471327
NavigationBar {
1348-
screens.forEachIndexed { index, screen ->
1328+
bottomNavTabs.forEach { tab ->
13491329
NavigationBarItem(
1350-
icon = { Icon(screen.icon, contentDescription = null) },
1351-
label = { Text(screen.title) },
1352-
selected = selectedTab == index,
1330+
icon = { Icon(tab.icon, contentDescription = null) },
1331+
label = { Text(tab.label) },
1332+
selected = tab.matchesRoute(currentRoute),
13531333
onClick = {
1354-
selectedTab = index
1355-
navController.navigate(screen.route) {
1334+
if (currentRoute?.startsWith("nomadnet") == true) {
1335+
if (tab == NavTab.NOMADNET) {
1336+
// Already browsing; the site session ends via
1337+
// Close Site or Back, not by re-tapping the tab.
1338+
return@NavigationBarItem
1339+
}
1340+
// Browsing is modal over the tab tree: collapse
1341+
// the NomadNet stack first, then switch tabs
1342+
// normally. Popping (rather than saving state)
1343+
// guarantees a single browser view, so tab taps
1344+
// can never stack duplicates or fight over
1345+
// scroll position.
1346+
while (
1347+
navController.currentDestination?.route
1348+
?.startsWith("nomadnet") == true
1349+
) {
1350+
if (!navController.popBackStack()) break
1351+
}
1352+
}
1353+
navController.navigate(tab.tabRoute) {
13561354
popUpTo(navController.graph.startDestinationId) {
13571355
saveState = true
13581356
}
@@ -1741,7 +1739,6 @@ fun ColumbaNavigation(
17411739
navController.navigate("apk_sharing")
17421740
},
17431741
onNavigateToAnnounces = { filterType ->
1744-
selectedTab = 1 // Announces tab
17451742
val route =
17461743
if (filterType != null) {
17471744
"${Screen.Announces.route}?filterType=$filterType"
@@ -2422,7 +2419,6 @@ fun ColumbaNavigation(
24222419
},
24232420
onStartChat = { destHash, peerName ->
24242421
// Navigate back to chats tab
2425-
selectedTab = 0
24262422
navController.navigate(Screen.Chats.route) {
24272423
popUpTo(navController.graph.startDestinationId) {
24282424
saveState = true
@@ -2467,6 +2463,31 @@ fun ColumbaNavigation(
24672463
destinationHash = destHash,
24682464
initialPath = path,
24692465
onBackClick = { navController.popBackStack() },
2466+
// Standalone browser: after closing the site,
2467+
// pop back the way Back does.
2468+
onCloseSite = { navController.popBackStack() },
2469+
onOpenConversation = { conversationHash ->
2470+
val encodedHash = Uri.encode(conversationHash)
2471+
val encodedName = Uri.encode(conversationHash.take(12))
2472+
navController.navigate("messaging/$encodedHash/$encodedName")
2473+
},
2474+
)
2475+
}
2476+
2477+
// NomadNet tab home: reopens the last-browsed node,
2478+
// or shows the address-entry prompt on a fresh install.
2479+
appComposable(AppDestination.NOMADNET_HOME) {
2480+
DoubleBackToExitHandler(AppDestination.NOMADNET_HOME.routePattern)
2481+
val lastNodeHash = settingsState.nomadNetLastNodeHash
2482+
NomadNetBrowserScreen(
2483+
destinationHash = lastNodeHash.orEmpty(),
2484+
showHomeEntry = lastNodeHash.isNullOrEmpty(),
2485+
onBackClick = { navController.popBackStack() },
2486+
// Close Site on the tab home: closeSite() drops the
2487+
// persisted last-node hash, the state flow flips
2488+
// showHomeEntry, and the screen swaps to the address
2489+
// prompt in place - no navigation needed.
2490+
onCloseSite = {},
24702491
onOpenConversation = { conversationHash ->
24712492
val encodedHash = Uri.encode(conversationHash)
24722493
val encodedName = Uri.encode(conversationHash.take(12))

app/src/main/java/network/columba/app/navigation/AppDestination.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ enum class AppDestination(
120120
),
121121
externalNavigationPolicy = ExternalNavigationPolicy.REUSE_SAME_ENTITY,
122122
),
123+
NOMADNET_HOME(
124+
"nomadnet_home",
125+
backContract = BackContract.TOP_LEVEL,
126+
),
123127
OFFLINE_MAPS("offline_maps"),
124128
OFFLINE_MAP_DOWNLOAD(
125129
"offline_map_download?updateRegionId={updateRegionId}",
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package network.columba.app.navigation
2+
3+
import androidx.compose.material.icons.Icons
4+
import androidx.compose.material.icons.filled.Chat
5+
import androidx.compose.material.icons.filled.Language
6+
import androidx.compose.material.icons.filled.Map
7+
import androidx.compose.material.icons.filled.People
8+
import androidx.compose.material.icons.filled.Sensors
9+
import androidx.compose.material.icons.filled.Settings
10+
import androidx.compose.ui.graphics.vector.ImageVector
11+
12+
/**
13+
* Registry of destinations that may appear in the user-configurable bottom
14+
* navigation bar.
15+
*
16+
* The user chooses which tabs appear and in what order; the persisted form is
17+
* a comma-separated list of [id] values (see SettingsRepository). [SETTINGS]
18+
* is pinned: it is always present and always rendered last, so Settings can
19+
* never be removed from the bar. [sanitize] is the single authority for what
20+
* the bar renders - it drops unknown ids (forward/backward compatible),
21+
* dedupes, enforces the pin, and clamps the total to [MAX_TABS].
22+
*
23+
* [routePrefix] drives tab selection: a tab is selected while the current
24+
* navigation route starts with its prefix, which keeps a tab highlighted
25+
* across parameterized routes (e.g. `announce_stream?filterType=all`).
26+
*/
27+
enum class NavTab(
28+
val id: String,
29+
val routePrefix: String,
30+
val tabRoute: String,
31+
val label: String,
32+
val icon: ImageVector,
33+
) {
34+
CHATS("chats", "chats", "chats", "Chats", Icons.Default.Chat),
35+
ANNOUNCES("announces", "announce_stream", "announce_stream", "Announces", Icons.Default.Sensors),
36+
CONTACTS("contacts", "contacts", "contacts", "Contacts", Icons.Default.People),
37+
MAP("map", "map", "map", "Map", Icons.Default.Map),
38+
39+
// The NomadNet tab lands on nomadnet_home (which resolves the last-browsed
40+
// node); its selection prefix also covers nomadnet_browser/... so the tab
41+
// stays highlighted while browsing pages with the bar visible.
42+
NOMADNET("nomadnet", "nomadnet", "nomadnet_home", "NomadNet", Icons.Default.Language),
43+
SETTINGS("settings", "settings", "settings", "Settings", Icons.Default.Settings),
44+
;
45+
46+
/** True when [currentRoute] belongs to this tab. */
47+
fun matchesRoute(currentRoute: String?): Boolean =
48+
currentRoute != null && currentRoute.startsWith(routePrefix)
49+
50+
companion object {
51+
/** Maximum number of tabs in the bar, including the pinned Settings tab. */
52+
const val MAX_TABS = 5
53+
54+
/** Bar layout shown before the user configures anything. */
55+
val DEFAULT: List<NavTab> = listOf(CHATS, CONTACTS, MAP, SETTINGS)
56+
57+
/** All tabs the user may toggle in the settings card (Settings is pinned). */
58+
val CONFIGURABLE: List<NavTab> = entries.filter { it != SETTINGS }
59+
60+
fun fromId(id: String): NavTab? = entries.firstOrNull { it.id == id }
61+
62+
/**
63+
* Parse and normalize a persisted CSV of tab ids into the exact list of
64+
* tabs the bottom bar should render. Malformed, unknown, or duplicate
65+
* entries are dropped; Settings is appended if missing; the result is
66+
* clamped to [MAX_TABS] entries. Falls back to [DEFAULT] when nothing
67+
* valid remains, so the bar can never render empty.
68+
*/
69+
fun sanitize(csv: String?): List<NavTab> {
70+
val parsed =
71+
csv
72+
?.split(',')
73+
?.map { it.trim() }
74+
?.mapNotNull { fromId(it) }
75+
?.distinct()
76+
.orEmpty()
77+
if (parsed.isEmpty()) return DEFAULT
78+
val withoutSettings = parsed.filter { it != SETTINGS }
79+
val overflow = withoutSettings.size - (MAX_TABS - 1)
80+
val kept = if (overflow > 0) withoutSettings.dropLast(overflow) else withoutSettings
81+
return kept + SETTINGS
82+
}
83+
}
84+
}

app/src/main/java/network/columba/app/repository/SettingsRepository.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,8 @@ class SettingsRepository
141141
val MAP_MARKER_DECLUTTER_ENABLED = booleanPreferencesKey("map_marker_declutter_enabled")
142142
val MAP_STYLE_PREFERENCE = stringPreferencesKey("map_style_preference")
143143
val NOMADNET_RENDERING_MODE = stringPreferencesKey("nomadnet_rendering_mode")
144+
val NOMADNET_LAST_NODE = stringPreferencesKey("nomadnet_last_node")
145+
val BOTTOM_NAV_TABS = stringPreferencesKey("bottom_nav_tabs")
144146
val HTTP_ENABLED_FOR_DOWNLOAD = booleanPreferencesKey("http_enabled_for_download")
145147

146148
// Privacy preferences
@@ -1479,6 +1481,68 @@ class SettingsRepository
14791481
}
14801482
}
14811483

1484+
/**
1485+
* Flow of the destination hash of the last NomadNet page the user loaded,
1486+
* or null when none. The bottom-nav NomadNet tab navigates here so the
1487+
* tab reopens where the user left off; a fresh install falls back to the
1488+
* caller-provided default entry node.
1489+
*/
1490+
val nomadNetLastNodeHashFlow: Flow<String?> =
1491+
context.dataStore.data
1492+
.map { preferences -> preferences[PreferencesKeys.NOMADNET_LAST_NODE] }
1493+
.distinctUntilChanged()
1494+
1495+
/**
1496+
* Remember the destination hash of the most recently loaded NomadNet page.
1497+
* [whileActive] is evaluated inside the DataStore transaction (under the
1498+
* edit mutex), so callers can guard against a concurrent
1499+
* [clearNomadNetLastNodeHash] resurrecting a just-closed site: either the
1500+
* predicate fails and nothing is written, or the write lands before any
1501+
* later clear and is properly erased by it.
1502+
*/
1503+
suspend fun saveNomadNetLastNodeHash(
1504+
nodeHash: String,
1505+
whileActive: () -> Boolean = { true },
1506+
) {
1507+
if (nodeHash.isBlank()) return
1508+
context.dataStore.edit { preferences ->
1509+
if (whileActive()) {
1510+
preferences[PreferencesKeys.NOMADNET_LAST_NODE] = nodeHash
1511+
}
1512+
}
1513+
}
1514+
1515+
/**
1516+
* Forget the last-browsed NomadNet page (Close Site). The bottom-nav
1517+
* NomadNet tab then reopens at the address-entry prompt instead of the
1518+
* closed site.
1519+
*/
1520+
suspend fun clearNomadNetLastNodeHash() {
1521+
context.dataStore.edit { preferences ->
1522+
preferences.remove(PreferencesKeys.NOMADNET_LAST_NODE)
1523+
}
1524+
}
1525+
1526+
/**
1527+
* Flow of the user-configured bottom navigation tabs, stored as a
1528+
* comma-separated list of [network.columba.app.navigation.NavTab] ids.
1529+
* Emits null before first configuration; consumers map through
1530+
* [network.columba.app.navigation.NavTab.sanitize] for a normalized list.
1531+
*/
1532+
val bottomNavTabsFlow: Flow<String?> =
1533+
context.dataStore.data
1534+
.map { preferences -> preferences[PreferencesKeys.BOTTOM_NAV_TABS] }
1535+
.distinctUntilChanged()
1536+
1537+
/**
1538+
* Persist the bottom navigation tab layout as a comma-separated id list.
1539+
*/
1540+
suspend fun saveBottomNavTabs(csv: String) {
1541+
context.dataStore.edit { preferences ->
1542+
preferences[PreferencesKeys.BOTTOM_NAV_TABS] = csv
1543+
}
1544+
}
1545+
14821546
/**
14831547
* Flow of the map marker declutter enabled setting.
14841548
* When enabled (default), overlapping markers are spread to improve readability.

app/src/main/java/network/columba/app/ui/screens/AnnounceDetailScreen.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,10 @@ fun AnnounceDetailScreen(
201201
.fillMaxSize()
202202
.padding(paddingValues)
203203
.verticalScroll(rememberScrollState())
204-
.padding(16.dp),
204+
// The outer bottom nav bar is visible on this screen, so
205+
// clear its height (same 88.dp convention as other
206+
// nav-visible screens) for the last card.
207+
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
205208
verticalArrangement = Arrangement.spacedBy(16.dp),
206209
) {
207210
// Identicon header

0 commit comments

Comments
 (0)