feat(ui): move tab management into ChatState and unify navigation

- Add AppTab enum to ChatState to centralize tab state management
- Move selectedTab state flow from shell composition to ChatState for global access
- Update ChannelManager to automatically switch to Public tab when joining channels
- Add showTab() method to ChatViewModel to expose tab selection to all navigation points
- Integrate tab switching into back navigation handler for consistent unwind order
- Update selectLocationChannel() to switch to Public tab on geohash notification taps
- Remove BackHandler and local tab state from MainScaffold composition
- Clarify navigation behavior with detailed code comments explaining tab switching rules
- Add ChannelTabRoutingTest to verify tab routing on channel selection
- Ensure all navigation paths (notifications, channel joins, password prompts) consistently manage tab state from a single location
This commit is contained in:
SammyZi 2026-09-15 18:53:57 +05:30
parent 357e7f65ee
commit 04d4429ef2
7 changed files with 142 additions and 106 deletions

View File

@ -810,7 +810,7 @@ class MainActivity : OrientationAwareActivity() {
if (peerID != null) {
Log.d("MainActivity", "Opening private chat with $senderNickname (peerID: $peerID) from notification")
// Open the conversation; the shell switches to the Chats tab behind it
// Open the conversation; it overlays whichever tab is showing
chatViewModel.openPrivateChat(peerID)
// Clear notifications for this sender since user is now viewing the chat

View File

@ -113,6 +113,14 @@ class ChannelManager(
state.setSelectedPrivateChatPeer(null)
state.setSelectedLocationChannel(ChannelID.Mesh)
onSwitchToMeshLocation()
// A channel only renders on the Public tab. Every way into one - a channel row in Chats,
// the password prompt (which can be answered from any tab), /join - funnels through here,
// so this is the one place that has to bring the timeline on screen. Leaving a channel
// (null) returns to the mesh timeline without moving the user off their current tab.
if (channel != null) {
state.setSelectedTab(AppTab.Public)
}
// Clear unread count
channel?.let { ch ->

View File

@ -13,6 +13,15 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* The app's four destinations, in bar order.
*
* `Public` is the mesh/geohash timeline the app used to be entirely made of; the other three were
* modal bottom sheets before this became a tabbed app. Labels and icons are mapped in the
* navigation bar, keeping this plain state.
*/
enum class AppTab { Public, Chats, People, Settings }
/**
* Centralized state definitions and data classes for the chat system
*/
@ -126,6 +135,12 @@ class ChatState(
private val _openPrivateChatPeer = MutableStateFlow<String?>(null)
val openPrivateChatPeer: StateFlow<String?> = _openPrivateChatPeer.asStateFlow()
// Lives here rather than in the shell's composition so that anything which changes what the
// timeline shows - a channel switch, a notification tap - can also bring the timeline on
// screen, and so back navigation has a single unwind path in ChatViewModel.handleBackPressed.
private val _selectedTab = MutableStateFlow(AppTab.Public)
val selectedTab: StateFlow<AppTab> = _selectedTab.asStateFlow()
private val _showVerificationSheet = MutableStateFlow(false)
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
@ -190,6 +205,7 @@ class ChatState(
fun getPeerSessionStatesValue() = _peerSessionStates.value
fun getPeerFingerprintsValue() = _peerFingerprints.value
fun getShowAppInfoValue() = _showAppInfo.value
fun getSelectedTabValue() = _selectedTab.value
fun getGeohashPeopleValue() = _geohashPeople.value
fun getOpenPrivateChatPeerValue() = _openPrivateChatPeer.value
@ -317,6 +333,10 @@ class ChatState(
_showAppInfo.value = show
}
fun setSelectedTab(tab: AppTab) {
_selectedTab.value = tab
}
fun setShowVerificationSheet(show: Boolean) {
_showVerificationSheet.value = show
}

View File

@ -400,6 +400,7 @@ class ChatViewModel(
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
val openPrivateChatPeer: StateFlow<String?> = state.openPrivateChatPeer
val selectedTab: StateFlow<AppTab> = state.selectedTab
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
@ -1648,6 +1649,10 @@ class ChatViewModel(
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
geohashViewModel.selectLocationChannel(channel)
// Its caller is a geohash notification tap, which can arrive on any tab; the channel only
// renders on Public. (The channels sheet selects through the location manager directly,
// and is only reachable from Public already.)
state.setSelectedTab(AppTab.Public)
}
/**
@ -1672,6 +1677,10 @@ class ChatViewModel(
state.setPasswordPromptChannel(null)
}
fun showTab(tab: AppTab) {
state.setSelectedTab(tab)
}
/**
* Handle Android back navigation
* Returns true if the back press was handled, false if it should be passed to the system
@ -1694,6 +1703,13 @@ class ChatViewModel(
endPrivateChat()
true
}
// Return to the timeline tab. Kept in this one unwind path rather than in a separate
// BackHandler in the shell, so the order - dialogs, conversation, tab, channel, exit -
// is decided in a single place.
state.getSelectedTabValue() != AppTab.Public -> {
showTab(AppTab.Public)
true
}
// Exit channel view
state.getCurrentChannelValue() != null -> {
switchToChannel(null)

View File

@ -1,6 +1,5 @@
package com.bitchat.android.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -33,11 +32,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -46,50 +43,24 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitchat.android.R
import com.bitchat.android.ui.theme.BitchatFontFamily
/**
* The app's four destinations, in bar order.
*
* `Public` is the mesh/geohash timeline the app used to be entirely made of; the other three were
* modal bottom sheets before this became a tabbed app.
*/
enum class AppTab(
val labelRes: Int,
val iconRes: Int? = null,
val iconVector: ImageVector? = null
) {
Public(R.string.tab_public, iconRes = R.drawable.ic_spec_globe),
Chats(R.string.tab_chats, iconRes = R.drawable.ic_spec_envelope),
People(R.string.people, iconRes = R.drawable.ic_spec_people),
Settings(R.string.about_tab_settings, iconVector = Icons.Outlined.Settings)
}
/**
* Tab shell hosting the whole app.
*
* A `when` over a saveable enum rather than a NavHost: four fixed destinations sharing one
* ChatViewModel need no route DSL, no argument encoding and no graph. The one nested destination -
* an open conversation - is a nullable peer id held by the Chats tab.
* A `when` over an enum rather than a NavHost: four fixed destinations sharing one ChatViewModel
* need no route DSL, no argument encoding and no graph. The one nested destination - an open
* conversation - is a nullable peer id that overlays every tab.
*/
@Composable
fun MainScaffold(viewModel: ChatViewModel, modifier: Modifier = Modifier) {
// Public leads the bar and is the default: a first-run user has no conversations, and the mesh
// timeline is the thing the app exists for.
var tab by rememberSaveable { mutableStateOf(AppTab.Public) }
// Which conversation is open is the ViewModel's business, not the shell's: notification taps,
// deep links and alias re-resolution all set it from outside compose. The shell only reads it.
// Tab and open conversation are both ViewModel state, not shell state: channel switches and
// notification taps change them from outside compose, and back unwinds them in one place
// (ChatViewModel.handleBackPressed, via the activity's back callback). The shell only reads.
val tab by viewModel.selectedTab.collectAsStateWithLifecycle()
val openConversation by viewModel.openPrivateChatPeer.collectAsStateWithLifecycle()
val openConversationFor: (String) -> Unit = { peerID ->
viewModel.openPrivateChat(peerID)
tab = AppTab.Chats
}
// Closing a conversation is already handled by the activity's back callback via
// ChatViewModel.handleBackPressed, so this only covers the remaining case: returning to the
// default tab before back is allowed to leave the app.
BackHandler(enabled = openConversation == null && tab != AppTab.Public) {
tab = AppTab.Public
viewModel.showTab(AppTab.Chats)
}
val inConversation = openConversation != null
@ -108,7 +79,7 @@ fun MainScaffold(viewModel: ChatViewModel, modifier: Modifier = Modifier) {
BitchatNavigationBar(
viewModel = viewModel,
selected = tab,
onSelect = { tab = it }
onSelect = viewModel::showTab
)
}
}
@ -141,7 +112,7 @@ fun MainScaffold(viewModel: ChatViewModel, modifier: Modifier = Modifier) {
AppTab.Public -> ChatScreen(
viewModel = viewModel,
onOpenPeopleTab = { tab = AppTab.People }
onOpenPeopleTab = { viewModel.showTab(AppTab.People) }
)
AppTab.People -> TabScreen(
@ -311,7 +282,14 @@ private fun BitchatNavigationBar(
tonalElevation = 0.dp
) {
AppTab.entries.forEach { entry ->
val label = stringResource(entry.labelRes)
val label = stringResource(
when (entry) {
AppTab.Public -> R.string.tab_public
AppTab.Chats -> R.string.tab_chats
AppTab.People -> R.string.people
AppTab.Settings -> R.string.about_tab_settings
}
)
NavigationBarItem(
selected = entry == selected,
onClick = { onSelect(entry) },
@ -328,15 +306,21 @@ private fun BitchatNavigationBar(
}
}
) {
if (entry.iconVector != null) {
val iconRes = when (entry) {
AppTab.Public -> R.drawable.ic_spec_globe
AppTab.Chats -> R.drawable.ic_spec_envelope
AppTab.People -> R.drawable.ic_spec_people
AppTab.Settings -> null // no spec glyph for settings
}
if (iconRes != null) {
Icon(
imageVector = entry.iconVector,
painter = painterResource(iconRes),
contentDescription = label,
modifier = Modifier.size(22.dp)
)
} else {
Icon(
painter = painterResource(entry.iconRes!!),
imageVector = Icons.Outlined.Settings,
contentDescription = label,
modifier = Modifier.size(22.dp)
)

View File

@ -1,17 +1,12 @@
package com.bitchat.android.ui
import android.content.pm.ActivityInfo
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Display
import androidx.appcompat.app.AppCompatActivity
import com.bitchat.android.utils.DeviceUtils
/**
* Base activity for the app's own screens: device-appropriate orientation, and the display's
* highest refresh rate.
*
* Base activity that automatically sets orientation based on device type.
* Tablets can rotate to landscape, phones are locked to portrait.
*/
abstract class OrientationAwareActivity : AppCompatActivity() {
@ -19,58 +14,6 @@ abstract class OrientationAwareActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setOrientationBasedOnDeviceType()
requestHighestRefreshRate()
}
/**
* Ask the compositor for the fastest mode this panel offers.
*
* Only modes at the resolution already in use are considered - a panel typically lists its
* high refresh rates at more than one resolution, and picking purely by refresh rate would
* silently drop the screen to a lower one.
*
* This is a request, not a guarantee: the system still overrides it for battery saver, for a
* user-set refresh-rate preference, and on panels that gate high rates by content.
*/
private fun requestHighestRefreshRate() {
val display: Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
display
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay
}
val current = display?.mode ?: return
val sameResolution = display.supportedModes.filter {
it.physicalWidth == current.physicalWidth &&
it.physicalHeight == current.physicalHeight
}
val fastest = sameResolution.maxByOrNull { it.refreshRate } ?: return
// No early return when the reported mode already looks fastest. Display.mode can name the
// panel's default mode while the compositor is actually driving it slower, and skipping
// the request in that case is exactly the situation this method exists to fix.
window.attributes = window.attributes.apply {
preferredDisplayModeId = fastest.modeId
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Second, weaker signal. Some vendors honour the float hint when they ignore a
// specific mode id.
preferredRefreshRate = fastest.refreshRate
}
}
// Logged in full because the request is frequently refused and the mode list is the only
// way to tell why: a panel whose high rates live at another resolution, a vendor
// refresh-rate setting, or a device that simply has one mode.
Log.i(
TAG,
"Requested ${fastest.refreshRate} Hz (mode ${fastest.modeId}); " +
"current ${current.refreshRate} Hz at " +
"${current.physicalWidth}x${current.physicalHeight}; " +
"modes=" + display.supportedModes.joinToString {
"${it.physicalWidth}x${it.physicalHeight}@${it.refreshRate}"
}
)
}
private fun setOrientationBasedOnDeviceType() {
@ -82,8 +25,4 @@ abstract class OrientationAwareActivity : AppCompatActivity() {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
}
private companion object {
const val TAG = "OrientationAwareActivity"
}
}

View File

@ -0,0 +1,69 @@
package com.bitchat.android.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
/**
* A channel only renders on the Public tab, so every way into one has to bring that tab on screen.
*
* Getting this wrong is silent: the channel switches in state, but the user is left looking at
* Chats (or People, or Settings) with no timeline in sight.
*/
@RunWith(RobolectricTestRunner::class)
class ChannelTabRoutingTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@OptIn(ExperimentalCoroutinesApi::class)
private val testScope = TestScope(UnconfinedTestDispatcher())
private val state = ChatState(scope = testScope)
private val channelManager = ChannelManager(
state = state,
messageManager = MessageManager(state = state),
dataManager = DataManager(context = context),
coroutineScope = testScope
)
@Test
fun `defaults to the Public tab`() {
assertEquals(AppTab.Public, state.getSelectedTabValue())
}
@Test
fun `opening a channel from another tab shows the timeline`() {
// The channel row in the Chats tab.
state.setSelectedTab(AppTab.Chats)
channelManager.switchToChannel("#general")
assertEquals("#general", state.getCurrentChannelValue())
assertEquals(AppTab.Public, state.getSelectedTabValue())
}
@Test
fun `joining a channel from another tab shows the timeline`() {
// The password prompt is hosted by the shell and can be confirmed from any tab.
state.setSelectedTab(AppTab.Settings)
channelManager.joinChannel("general", password = null, myPeerID = "peer-id")
assertEquals("#general", state.getCurrentChannelValue())
assertEquals(AppTab.Public, state.getSelectedTabValue())
}
@Test
fun `leaving a channel does not move the user off their tab`() {
channelManager.switchToChannel("#general")
state.setSelectedTab(AppTab.People)
channelManager.switchToChannel(null)
assertEquals(AppTab.People, state.getSelectedTabValue())
}
}