mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
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:
parent
357e7f65ee
commit
04d4429ef2
@ -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
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user