mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Rework MainActivity navigation to properly suppor empty state.
This commit is contained in:
committed by
Greyson Parrelli
parent
58d2fbc94e
commit
638f718d7c
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user