Rework MainActivity navigation to properly suppor empty state.

This commit is contained in:
Alex Hart
2025-08-13 14:36:07 -03:00
committed by Greyson Parrelli
parent 58d2fbc94e
commit 638f718d7c
4 changed files with 80 additions and 113 deletions

View File

@@ -23,6 +23,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -48,6 +49,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
@@ -276,9 +278,10 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
val mainNavigationDetailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle()
LaunchedEffect(mainNavigationState.selectedDestination) {
when (mainNavigationState.selectedDestination) {
LaunchedEffect(mainNavigationState.currentListLocation) {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS -> toolbarViewModel.presentToolbarForConversationListFragment()
MainNavigationListLocation.ARCHIVE -> toolbarViewModel.presentToolbarForConversationListArchiveFragment()
MainNavigationListLocation.CALLS -> toolbarViewModel.presentToolbarForCallLogFragment()
@@ -359,7 +362,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
Box(
modifier = Modifier.weight(1f)
) {
when (val destination = mainNavigationState.selectedDestination) {
when (val destination = mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
@@ -404,7 +407,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
},
detailContent = {
when (val destination = wrappedNavigator.currentDestination?.contentKey) {
when (val destination = mainNavigationDetailLocation) {
is MainNavigationDetailLocation.Conversation -> {
val fragmentState = key(destination) { rememberFragmentState() }
AndroidFragment(
@@ -418,6 +421,22 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
.fillMaxSize()
)
}
MainNavigationDetailLocation.Empty -> {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
Image(
painter = painterResource(R.drawable.ic_signal_logo_large),
contentDescription = null,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
},
paneExpansionDragHandle = if (contentLayoutData.hasDragHandle()) {
@@ -695,6 +714,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
}
}

View File

@@ -88,7 +88,7 @@ data class MainNavigationState(
val storiesCount: Int = 0,
val storyFailure: Boolean = false,
val isStoriesFeatureEnabled: Boolean = true,
val selectedDestination: MainNavigationListLocation = MainNavigationListLocation.CHATS,
val currentListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS,
val compact: Boolean = false
)
@@ -123,7 +123,7 @@ fun MainNavigationBar(
MainNavigationListLocation.STORIES -> state.storiesCount
}
val selected = state.selectedDestination == destination
val selected = state.currentListLocation == destination
NavigationBarItem(
selected = selected,
icon = {
@@ -216,7 +216,7 @@ fun MainNavigationRail(
containerColor = SignalTheme.colors.colorSurface1,
header = {
MainFloatingActionButtons(
destination = state.selectedDestination,
destination = state.currentListLocation,
callback = mainFloatingActionButtonsCallback,
modifier = Modifier.padding(vertical = 40.dp)
)
@@ -231,7 +231,7 @@ fun MainNavigationRail(
}
entries.forEachIndexed { idx, destination ->
val selected = state.selectedDestination == destination
val selected = state.currentListLocation == destination
Box {
NavigationRailItem(
@@ -346,7 +346,7 @@ private fun MainNavigationRailPreview() {
chatsCount = 500,
callsCount = 10,
storiesCount = 5,
selectedDestination = selected
currentListLocation = selected
),
mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty,
onDestinationSelected = { selected = it }
@@ -365,7 +365,7 @@ private fun MainNavigationBarPreview() {
chatsCount = 500,
callsCount = 10,
storiesCount = 5,
selectedDestination = selected,
currentListLocation = selected,
compact = false
),
onDestinationSelected = { selected = it }

View File

@@ -7,9 +7,7 @@ package org.thoughtcrime.securesms.main
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.reactivex.rxjava3.core.Observable
@@ -38,7 +36,9 @@ class MainNavigationViewModel(
) : ViewModel() {
private val megaphoneRepository = AppDependencies.megaphoneRepository
private var navigator: LegacyNavigator? = null
private var navigator: ThreePaneScaffoldNavigator<Any>? = null
private var navigatorScope: CoroutineScope? = null
private var goToLegacyDetailLocation: ((MainNavigationDetailLocation) -> Unit)? = null
/**
* The latest detail location that has been requested, for consumption by other components.
@@ -46,6 +46,7 @@ class MainNavigationViewModel(
private val internalDetailLocation = MutableStateFlow(initialDetailLocation)
val detailLocation: StateFlow<MainNavigationDetailLocation> = internalDetailLocation
val detailLocationObservable: Observable<MainNavigationDetailLocation> = internalDetailLocation.asObservable()
var latestConversationLocation: MainNavigationDetailLocation.Conversation? = null
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -58,7 +59,7 @@ class MainNavigationViewModel(
private val notificationProfilesRepository: NotificationProfilesRepository = NotificationProfilesRepository()
private val internalMainNavigationState = MutableStateFlow(MainNavigationState(selectedDestination = initialListLocation))
private val internalMainNavigationState = MutableStateFlow(MainNavigationState(currentListLocation = initialListLocation))
val mainNavigationState: StateFlow<MainNavigationState> = internalMainNavigationState
/**
@@ -90,29 +91,10 @@ class MainNavigationViewModel(
* such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation.
*/
fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator<Any>, goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit): ThreePaneScaffoldNavigator<Any> {
val previous = this.navigator
val wrapped = LegacyNavigator(composeScope, threePaneScaffoldNavigator, goToLegacyDetailLocation)
this.navigator = wrapped
if (previous != null) {
val destination = previous.currentDestination?.contentKey ?: return wrapped
if (destination is MainNavigationListLocation) {
goTo(destination)
}
} else {
goTo(mainNavigationState.value.selectedDestination)
}
if (previous != null) {
val destination = previous.currentDestination?.contentKey ?: return wrapped
if (destination is MainNavigationDetailLocation) {
goTo(destination)
}
} else {
goTo(internalDetailLocation.value)
}
return wrapped
this.goToLegacyDetailLocation = goToLegacyDetailLocation
this.navigatorScope = composeScope
this.navigator = threePaneScaffoldNavigator
return threePaneScaffoldNavigator
}
/**
@@ -120,20 +102,48 @@ class MainNavigationViewModel(
* "default" location to that specified, and we will route the user there when the navigator is set.
*/
fun goTo(location: MainNavigationDetailLocation) {
if (navigator == null) {
internalDetailLocation.update {
location
if (!SignalStore.internal.largeScreenUi) {
goToLegacyDetailLocation?.invoke(location)
return
}
internalDetailLocation.update {
location
}
val focusedPane = when (location) {
is MainNavigationDetailLocation.Empty -> {
ThreePaneScaffoldRole.Secondary
}
is MainNavigationDetailLocation.Conversation -> {
latestConversationLocation = location
ThreePaneScaffoldRole.Primary
}
}
navigator?.composeScope?.launch {
navigator?.navigateTo(ThreePaneScaffoldRole.Primary, location)
navigatorScope?.launch {
navigator?.navigateTo(focusedPane)
}
}
fun goTo(location: MainNavigationListLocation) {
if (location != MainNavigationListLocation.CHATS) {
internalDetailLocation.update {
MainNavigationDetailLocation.Empty
}
} else {
internalDetailLocation.update {
latestConversationLocation ?: MainNavigationDetailLocation.Empty
}
}
internalMainNavigationState.update {
it.copy(selectedDestination = location)
it.copy(currentListLocation = location)
}
navigatorScope?.launch {
navigator?.navigateTo(ThreePaneScaffoldRole.Secondary)
}
}
@@ -193,13 +203,11 @@ class MainNavigationViewModel(
private fun onTabSelected(destination: MainNavigationListLocation) {
viewModelScope.launch {
val currentTab = internalMainNavigationState.value.selectedDestination
val currentTab = internalMainNavigationState.value.currentListLocation
if (currentTab == destination) {
internalTabClickEvents.emit(destination)
} else {
internalMainNavigationState.update {
it.copy(selectedDestination = destination)
}
goTo(destination)
}
}
}
@@ -215,65 +223,4 @@ class MainNavigationViewModel(
enum class NavigationEvent {
STORY_CAMERA_FIRST
}
/**
* ScaffoldNavigator wrapper that delegates to a default implementation
* Ensures we properly update `internalDetailLocation` as the user moves between
* screens.
*
* Delegates to a legacy method if the user is not a large-screen-ui enabled user.
*/
@Stable
private inner class LegacyNavigator(
val composeScope: CoroutineScope,
private val delegate: ThreePaneScaffoldNavigator<Any>,
private val goToLegacyDetailLocation: (MainNavigationDetailLocation) -> Unit
) : ThreePaneScaffoldNavigator<Any> by delegate {
/**
* Due to some weirdness with `navigateBack`, we don't seem to be able to execute
* code after running the delegate method. So instead, we mark that we saw the call
* and then handle updates in `seekBack`.
*/
private var didNavigateBack: Boolean = false
/**
* If we're not a large screen user, this delegates to the legacy method.
* Otherwise, we will delegate to the delegate, and update our detail location.
*/
override suspend fun navigateTo(pane: ThreePaneScaffoldRole, contentKey: Any?) {
if (!SignalStore.internal.largeScreenUi && contentKey is MainNavigationDetailLocation) {
goToLegacyDetailLocation(contentKey)
} else if (contentKey is MainNavigationDetailLocation.Conversation) {
delegate.navigateTo(pane, contentKey)
}
if (SignalStore.internal.largeScreenUi && contentKey is MainNavigationDetailLocation) {
internalDetailLocation.emit(contentKey)
}
}
/**
* Marks the back, and delegates to the delegate.
*/
override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean {
didNavigateBack = true
return delegate.navigateBack(backNavigationBehavior)
}
/**
* Delegates to the delegate, and then consumes the back. If back is consumed, we will update
* the internal detail location.
*/
override suspend fun seekBack(backNavigationBehavior: BackNavigationBehavior, fraction: Float) {
delegate.seekBack(backNavigationBehavior, fraction)
if (didNavigateBack) {
didNavigateBack = false
val destination = currentDestination?.contentKey as? MainNavigationDetailLocation ?: MainNavigationDetailLocation.Empty
internalDetailLocation.emit(destination)
}
}
}
}

View File

@@ -103,7 +103,7 @@ public abstract class AudioManagerCompat {
Log.w(TAG, "isSpeakerphoneOn: Failed to find communication device.");
return false;
} else {
return audioDeviceInfo.getType() == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER;
return AudioDeviceMapping.fromPlatformType(audioDeviceInfo.getType()) == SignalAudioManager.AudioDevice.SPEAKER_PHONE;
}
} else {
return audioManager.isSpeakerphoneOn();
@@ -112,16 +112,16 @@ public abstract class AudioManagerCompat {
public void setSpeakerphoneOn(boolean on) {
if (Build.VERSION.SDK_INT >= 31) {
int desiredType = on ? AudioDeviceInfo.TYPE_BUILTIN_SPEAKER : AudioDeviceInfo.TYPE_BUILTIN_EARPIECE;
AudioDeviceInfo candidate = getAvailableCommunicationDevices().stream()
.filter(audioDeviceInfo -> audioDeviceInfo.getType() == desiredType)
SignalAudioManager.AudioDevice audioDevice = on ? SignalAudioManager.AudioDevice.SPEAKER_PHONE : SignalAudioManager.AudioDevice.EARPIECE;
AudioDeviceInfo candidate = getAvailableCommunicationDevices().stream()
.filter(it -> AudioDeviceMapping.fromPlatformType(it.getType()) == audioDevice)
.findFirst()
.orElse(null);
if (candidate != null) {
setCommunicationDevice(candidate);
} else {
Log.w(TAG, "setSpeakerphoneOn: Failed to find candidate for device type {" + desiredType + "}. Falling back on deprecated method.");
Log.w(TAG, "setSpeakerphoneOn: Failed to find candidate for SignalAudioDevice {" + audioDevice + "}. Falling back on deprecated method.");
audioManager.setSpeakerphoneOn(on);
}
} else {