From 04d4429ef23437507173fde2336c10f531d57243 Mon Sep 17 00:00:00 2001 From: SammyZi Date: Tue, 15 Sep 2026 18:53:57 +0530 Subject: [PATCH] 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 --- .../java/com/bitchat/android/MainActivity.kt | 2 +- .../com/bitchat/android/ui/ChannelManager.kt | 8 +++ .../java/com/bitchat/android/ui/ChatState.kt | 20 ++++++ .../com/bitchat/android/ui/ChatViewModel.kt | 16 +++++ .../com/bitchat/android/ui/MainScaffold.kt | 70 +++++++------------ .../android/ui/OrientationAwareActivity.kt | 63 +---------------- .../android/ui/ChannelTabRoutingTest.kt | 69 ++++++++++++++++++ 7 files changed, 142 insertions(+), 106 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/ChannelTabRoutingTest.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index e7e4e033..fc8e50b7 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 43bc2085..8b905f9f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -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 -> diff --git a/app/src/main/java/com/bitchat/android/ui/ChatState.kt b/app/src/main/java/com/bitchat/android/ui/ChatState.kt index 196921a5..ed3f9a6a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatState.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatState.kt @@ -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(null) val openPrivateChatPeer: StateFlow = _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 = _selectedTab.asStateFlow() + private val _showVerificationSheet = MutableStateFlow(false) val showVerificationSheet: StateFlow = _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 } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 8906c3e0..3c9feed4 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -400,6 +400,7 @@ class ChatViewModel( val peerDirect: StateFlow> = state.peerDirect val showAppInfo: StateFlow = state.showAppInfo val openPrivateChatPeer: StateFlow = state.openPrivateChatPeer + val selectedTab: StateFlow = state.selectedTab val showVerificationSheet: StateFlow = state.showVerificationSheet val showSecurityVerificationSheet: StateFlow = state.showSecurityVerificationSheet val legacyPrivateMediaConsent: StateFlow = @@ -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) diff --git a/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt b/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt index ae55647a..4a3df2f9 100644 --- a/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt +++ b/app/src/main/java/com/bitchat/android/ui/MainScaffold.kt @@ -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) ) diff --git a/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt index a415edc5..a3914c90 100644 --- a/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/OrientationAwareActivity.kt @@ -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" - } } diff --git a/app/src/test/java/com/bitchat/android/ui/ChannelTabRoutingTest.kt b/app/src/test/java/com/bitchat/android/ui/ChannelTabRoutingTest.kt new file mode 100644 index 00000000..dfea8108 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChannelTabRoutingTest.kt @@ -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()) + } +}