Remove separate controllers and consolidate logic.

This commit is contained in:
Alex Hart
2025-09-23 13:58:34 -03:00
committed by Jeffrey Starke
parent 369085e162
commit 9b517a14cb
14 changed files with 320 additions and 282 deletions

View File

@@ -47,7 +47,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -61,7 +60,6 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.rememberNavController
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
@@ -104,9 +102,8 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.CallsNavHost
import org.thoughtcrime.securesms.main.ChatsNavHost
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.DetailsScreenNavHost
import org.thoughtcrime.securesms.main.InsetsViewModelUpdater
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
@@ -125,7 +122,11 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
import org.thoughtcrime.securesms.main.SnackbarState
import org.thoughtcrime.securesms.main.StoriesNavHost
import org.thoughtcrime.securesms.main.callNavGraphBuilder
import org.thoughtcrime.securesms.main.chatNavGraphBuilder
import org.thoughtcrime.securesms.main.navigateToDetailLocation
import org.thoughtcrime.securesms.main.rememberDetailNavHostController
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -305,7 +306,6 @@ 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.currentListLocation) {
when (mainNavigationState.currentListLocation) {
@@ -353,6 +353,35 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
)
val mutableInteractionSource = remember { MutableInteractionSource() }
val mainNavigationDetailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle(mainNavigationViewModel.earlyNavigationDetailLocationRequested ?: MainNavigationDetailLocation.Empty)
val chatsNavHostController = rememberDetailNavHostController {
chatNavGraphBuilder()
}
val callsNavHostController = rememberDetailNavHostController {
callNavGraphBuilder(it)
}
val storiesNavHostController = rememberDetailNavHostController {
storiesNavGraphBuilder()
}
LaunchedEffect(mainNavigationDetailLocation) {
mainNavigationViewModel.clearEarlyDetailLocation()
when (mainNavigationDetailLocation) {
is MainNavigationDetailLocation.Empty -> {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> chatsNavHostController
MainNavigationListLocation.CALLS -> callsNavHostController
MainNavigationListLocation.STORIES -> storiesNavHostController
}.navigateToDetailLocation(mainNavigationDetailLocation)
}
is MainNavigationDetailLocation.Chats -> chatsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Calls -> callsNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
is MainNavigationDetailLocation.Stories -> storiesNavHostController.navigateToDetailLocation(mainNavigationDetailLocation)
}
}
LaunchedEffect(mainNavigationDetailLocation) {
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
@@ -366,6 +395,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
InsetsViewModelUpdater()
AppScaffold(
navigator = wrappedNavigator,
paneExpansionState = paneExpansionState,
@@ -466,31 +497,24 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
},
detailContent = {
when (val location = mainNavigationDetailLocation) {
MainNavigationDetailLocation.Empty -> {
EmptyDetailScreen(contentLayoutData)
}
is MainNavigationDetailLocation.Chats -> {
ChatsNavHost(
currentDestination = location,
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
DetailsScreenNavHost(
navHostController = chatsNavHostController,
contentLayoutData = contentLayoutData
)
}
is MainNavigationDetailLocation.Calls -> {
CallsNavHost(
currentDestination = location,
MainNavigationListLocation.CALLS -> {
DetailsScreenNavHost(
navHostController = callsNavHostController,
contentLayoutData = contentLayoutData
)
}
is MainNavigationDetailLocation.Stories -> {
val storiesNavigationController = rememberNavController()
StoriesNavHost(
navHostController = storiesNavigationController,
startDestination = location,
MainNavigationListLocation.STORIES -> {
DetailsScreenNavHost(
navHostController = storiesNavHostController,
contentLayoutData = contentLayoutData
)
}
@@ -561,11 +585,11 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
)
}
is MainNavigationDetailLocation.Calls.CallLinkDetails -> {
is MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails -> {
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
}
is MainNavigationDetailLocation.Calls.EditCallLinkName -> {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
error("Unexpected subroute EditCallLinkName.")
}
@@ -787,7 +811,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
private fun handleConversationIntent(intent: Intent) {
if (ConversationIntents.isConversationIntent(intent)) {
Log.d(TAG, "Got conversation intent. Navigating to conversation.")
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
}

View File

@@ -59,7 +59,7 @@ class CallLinkDetailsActivity : FragmentActivity() {
private inner class Router : MainNavigationRouter {
override fun goTo(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Calls.EditCallLinkName -> {
is MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)

View File

@@ -114,7 +114,7 @@ class DefaultCallLinkDetailsCallback(
}
override fun onEditNameClicked() {
router.goTo(MainNavigationDetailLocation.Calls.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
router.goTo(MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {

View File

@@ -320,7 +320,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinkDetails(callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails(callLogRow.record.roomId))
}
}

View File

@@ -13,8 +13,10 @@ import androidx.core.view.WindowInsetsCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.InsetsViewModel
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
import kotlin.math.roundToInt
/**
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
@@ -64,6 +66,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private var insets: WindowInsetsCompat? = null
private var windowTypes: Int = InsetAwareConstraintLayout.windowTypes
private var verticalInsetOverride: InsetsViewModel.Insets = InsetsViewModel.Insets.Zero
private val windowInsetsListener = androidx.core.view.OnApplyWindowInsetsListener { _, insets ->
this.insets = insets
@@ -127,6 +130,22 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
}
fun applyInsets(insets: InsetsViewModel.Insets) {
verticalInsetOverride = insets
if (this.insets != null) {
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
}
}
fun clearVerticalInsetOverride() {
verticalInsetOverride = InsetsViewModel.Insets.Zero
if (this.insets != null) {
applyInsets(this.insets!!.getInsets(windowTypes), this.insets!!.getInsets(keyboardType))
}
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
keyboardStateListeners += listener
}
@@ -146,8 +165,8 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
val statusBar = windowInsets.top
val navigationBar = windowInsets.bottom
val statusBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.top else verticalInsetOverride.statusBar.roundToInt()
val navigationBar = if (verticalInsetOverride == InsetsViewModel.Insets.Zero) windowInsets.bottom else verticalInsetOverride.navBar.roundToInt()
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
@@ -156,7 +175,9 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
windowInsetsListeners.forEach {
it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd)
}
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)

View File

@@ -257,6 +257,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.longmessage.LongMessageFragment
import org.thoughtcrime.securesms.main.InsetsViewModel
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
@@ -349,6 +350,7 @@ import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil
import org.thoughtcrime.securesms.window.WindowSizeClass
import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass
import java.time.Instant
import java.time.LocalDateTime
@@ -483,6 +485,8 @@ class ConversationFragment :
private val shareDataTimestampViewModel: ShareDataTimestampViewModel by activityViewModels()
private val insetsViewModel: InsetsViewModel by activityViewModels()
private val inlineQueryController: InlineQueryResultsControllerV2 by lazy {
InlineQueryResultsControllerV2(
this,
@@ -595,8 +599,21 @@ class ConversationFragment :
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.toolbar.isBackInvokedCallbackEnabled = false
binding.root.setApplyRootInsets(!resources.getWindowSizeClass().isSplitPane())
binding.root.setUseWindowTypes(!resources.getWindowSizeClass().isSplitPane())
if (WindowSizeClass.isLargeScreenSupportEnabled()) {
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
binding.root.clearVerticalInsetOverride()
if (!resources.getWindowSizeClass().isSplitPane()) {
insetsViewModel.insets.collect {
binding.root.applyInsets(it)
}
}
}
}
}
binding.root.setApplyRootInsets(!WindowSizeClass.isLargeScreenSupportEnabled())
binding.root.setUseWindowTypes(!WindowSizeClass.isLargeScreenSupportEnabled())
disposables.bindTo(viewLifecycleOwner)
@@ -1601,6 +1618,7 @@ class ConversationFragment :
composeText.setDraftText(data.text)
inputPanel.clickOnComposeInput()
}
is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(data.location, MediaConstraints.getPushMediaConstraints())
is ShareOrDraftData.SetEditMessage -> {
composeText.setDraftText(data.draftText)
@@ -3219,9 +3237,13 @@ class ConversationFragment :
override fun onItemLongClick(itemView: View, item: MultiselectPart) {
Log.d(TAG, "onItemLongClick")
if (actionMode != null) { return }
if (actionMode != null) {
return
}
if (item.getMessageRecord().isInMemoryMessageRecord) { return }
if (item.getMessageRecord().isInMemoryMessageRecord) {
return
}
val messageRecord = item.getMessageRecord()
val recipient = viewModel.recipientSnapshot ?: return

View File

@@ -5,104 +5,43 @@
package org.thoughtcrime.securesms.main
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import kotlin.reflect.typeOf
/**
* A navigation host for the calls detail pane of [org.thoughtcrime.securesms.MainActivity].
*
* @param currentDestination The current calls destination to navigate to, containing routing information
* @param contentLayoutData Layout configuration data for responsive UI rendering
*/
@Composable
fun CallsNavHost(
currentDestination: MainNavigationDetailLocation.Calls,
contentLayoutData: MainContentLayoutData
) {
val navHostController = key(currentDestination.controllerKey) {
rememberNavController()
fun NavGraphBuilder.callNavGraphBuilder(navHostController: NavHostController) {
composable<MainNavigationDetailLocation.Empty> {
EmptyDetailScreen()
}
val startDestination = remember(currentDestination.controllerKey) {
MainNavigationDetailLocation.Calls.CallLinkDetails(currentDestination.controllerKey)
}
LaunchedEffect(navHostController) {
navHostController.enableOnBackPressed(true)
}
LaunchedEffect(currentDestination) {
if (currentDestination != startDestination) {
navHostController.navigate(currentDestination)
}
}
val mainNavigationViewModel = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
error("Should already exist.")
}
NavHost(
navController = navHostController,
startDestination = startDestination,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } },
modifier = Modifier.fillMaxSize()
composable<MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
composable<MainNavigationDetailLocation.Calls.CallLinkDetails>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinkDetails>()
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinks.CallLinkDetails>()
LaunchedEffect(route) {
mainNavigationViewModel.goTo(route)
}
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
MainActivityDetailContainer(contentLayoutData) {
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
}
composable<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName>()
val parent = navHostController.previousBackStackEntry ?: return@composable
composable<MainNavigationDetailLocation.Calls.EditCallLinkName>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
val parent = navHostController.getBackStackEntry(startDestination)
val route = it.toRoute<MainNavigationDetailLocation.Calls.EditCallLinkName>()
LaunchedEffect(route) {
mainNavigationViewModel.goTo(route)
}
MainActivityDetailContainer(contentLayoutData) {
CompositionLocalProvider(LocalViewModelStoreOwner provides parent) {
EditCallLinkNameScreen(roomId = route.callLinkRoomId)
}
}
CompositionLocalProvider(LocalViewModelStoreOwner provides parent) {
EditCallLinkNameScreen(roomId = route.callLinkRoomId)
}
}
}

View File

@@ -5,95 +5,41 @@
package org.thoughtcrime.securesms.main
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import kotlin.reflect.typeOf
/**
* A navigation host for the chats detail pane of [org.thoughtcrime.securesms.MainActivity].
*
* @param currentDestination The current calls destination to navigate to, containing routing information
* @param contentLayoutData Layout configuration data for responsive UI rendering
*/
@Composable
fun ChatsNavHost(
currentDestination: MainNavigationDetailLocation.Chats,
contentLayoutData: MainContentLayoutData
) {
val navHostController: NavHostController = key(currentDestination.controllerKey) {
rememberNavController()
fun NavGraphBuilder.chatNavGraphBuilder() {
composable<MainNavigationDetailLocation.Empty> {
EmptyDetailScreen()
}
val startDestination = remember(currentDestination.controllerKey) {
currentDestination as? MainNavigationDetailLocation.Chats.Conversation ?: error("Unsupported start destination.")
}
LaunchedEffect(currentDestination) {
if (currentDestination != startDestination) {
navHostController.navigate(currentDestination)
}
}
val mainNavigationViewModel = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
error("Should already exist.")
}
NavHost(
navController = navHostController,
startDestination = startDestination,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } },
modifier = Modifier.fillMaxSize()
composable<MainNavigationDetailLocation.Chats.Conversation>(
typeMap = mapOf(
typeOf<ConversationArgs>() to JsonSerializableNavType(ConversationArgs.serializer())
)
) {
composable<MainNavigationDetailLocation.Chats.Conversation>(
typeMap = mapOf(
typeOf<ConversationArgs>() to JsonSerializableNavType(ConversationArgs.serializer())
)
) {
val route = it.toRoute<MainNavigationDetailLocation.Chats.Conversation>()
val fragmentState = key(route) { rememberFragmentState() }
val context = LocalContext.current
val route = it.toRoute<MainNavigationDetailLocation.Chats.Conversation>()
val fragmentState = key(route) { rememberFragmentState() }
val context = LocalContext.current
LaunchedEffect(route) {
mainNavigationViewModel.goTo(route)
}
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
)
}
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.fillMaxSize()
)
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.annotation.Px
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalDensity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
class InsetsViewModel : ViewModel() {
private val internalInsets = MutableStateFlow(Insets.Zero)
val insets: StateFlow<Insets> = internalInsets
fun updateInsets(insets: Insets) {
internalInsets.update { insets }
}
data class Insets(
@param:Px val statusBar: Float,
@param:Px val navBar: Float
) {
companion object {
val Zero = Insets(0f, 0f)
}
}
}
@Composable
fun InsetsViewModelUpdater(
insetsViewModel: InsetsViewModel = viewModel { InsetsViewModel() }
) {
val statusBarInsets = WindowInsets.statusBars
val navigationBarInsets = WindowInsets.navigationBars
val statusBarPadding = statusBarInsets.asPaddingValues()
val navigationBarPadding = navigationBarInsets.asPaddingValues()
val density = LocalDensity.current
LaunchedEffect(statusBarPadding, navigationBarPadding, density) {
val statusBarPx = with(density) {
(statusBarPadding.calculateTopPadding() + statusBarPadding.calculateBottomPadding()).toPx()
}
val navBarPx = with(density) {
(navigationBarPadding.calculateTopPadding() + navigationBarPadding.calculateBottomPadding()).toPx()
}
insetsViewModel.updateInsets(
InsetsViewModel.Insets(
statusBar = statusBarPx,
navBar = navBarPx
)
)
}
}

View File

@@ -12,21 +12,26 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import androidx.navigation.createGraph
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
import org.thoughtcrime.securesms.R
@Composable
fun EmptyDetailScreen(
contentLayoutData: MainContentLayoutData
) {
fun EmptyDetailScreen() {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
@@ -40,17 +45,46 @@ fun EmptyDetailScreen(
}
@Composable
fun MainActivityDetailContainer(
contentLayoutData: MainContentLayoutData,
content: @Composable () -> Unit
) {
Box(
fun rememberDetailNavHostController(builder: NavGraphBuilder.(NavHostController) -> Unit): NavHostController {
val navHostController = rememberNavController()
val viewModelStore = LocalViewModelStoreOwner.current!!.viewModelStore
remember {
val graph = navHostController.createGraph(
startDestination = MainNavigationDetailLocation.Empty,
builder = { builder(navHostController) }
)
navHostController.setViewModelStore(viewModelStore)
navHostController.setGraph(graph, null)
graph
}
return navHostController
}
fun NavHostController.navigateToDetailLocation(location: MainNavigationDetailLocation) {
navigate(location) {
if (location.isContentRoot) {
popUpTo(graph.id) { inclusive = true }
}
}
}
@Composable
fun DetailsScreenNavHost(navHostController: NavHostController, contentLayoutData: MainContentLayoutData) {
NavHost(
navController = navHostController,
graph = navHostController.graph,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } },
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
content()
}
)
}

View File

@@ -18,17 +18,33 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
* Describes which content to display in the detail view.
*/
@Parcelize
sealed interface MainNavigationDetailLocation : Parcelable {
sealed class MainNavigationDetailLocation : Parcelable {
/**
* Flag utilized internally to determine whether the given route is displayed at the root
* of a task stack (or on top of Empty)
*/
@IgnoredOnParcel
open val isContentRoot: Boolean = false
@Serializable
data object Empty : MainNavigationDetailLocation
data object Empty : MainNavigationDetailLocation() {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
}
@Parcelize
sealed interface Chats : MainNavigationDetailLocation {
sealed class Chats : MainNavigationDetailLocation() {
val controllerKey: RecipientId
abstract val controllerKey: RecipientId
@Serializable
data class Conversation(val conversationArgs: ConversationArgs) : Chats {
data class Conversation(val conversationArgs: ConversationArgs) : Chats() {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@Transient
@IgnoredOnParcel
override val controllerKey: RecipientId = conversationArgs.recipientId
@@ -39,25 +55,33 @@ sealed interface MainNavigationDetailLocation : Parcelable {
* Content which can be displayed while the user is navigating the Calls tab.
*/
@Parcelize
sealed interface Calls : MainNavigationDetailLocation {
sealed class Calls : MainNavigationDetailLocation() {
val controllerKey: CallLinkRoomId
@Parcelize
sealed class CallLinks : Calls() {
@Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : Calls {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId
}
abstract val controllerKey: CallLinkRoomId
@Serializable
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : Calls {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId
@Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@Transient
@IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId
}
@Serializable
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : CallLinks() {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId
}
}
}
@Parcelize
sealed interface Stories : MainNavigationDetailLocation
sealed class Stories : MainNavigationDetailLocation()
}

View File

@@ -15,6 +15,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
@@ -32,8 +33,7 @@ import org.thoughtcrime.securesms.window.WindowSizeClass
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
class MainNavigationViewModel(
initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS,
initialDetailLocation: MainNavigationDetailLocation = MainNavigationDetailLocation.Empty
initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS
) : ViewModel(), MainNavigationRouter {
private val megaphoneRepository = AppDependencies.megaphoneRepository
@@ -44,11 +44,9 @@ class MainNavigationViewModel(
/**
* The latest detail location that has been requested, for consumption by other components.
*/
private val internalDetailLocation = MutableStateFlow(initialDetailLocation)
val detailLocation: StateFlow<MainNavigationDetailLocation> = internalDetailLocation
private val internalDetailLocation = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = internalDetailLocation
val detailLocationObservable: Observable<MainNavigationDetailLocation> = internalDetailLocation.asObservable()
var latestConversationLocation: MainNavigationDetailLocation.Chats.Conversation? = null
var latestCallsLocation: MainNavigationDetailLocation.Calls? = null
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -71,7 +69,8 @@ class MainNavigationViewModel(
val tabClickEvents: Observable<MainNavigationListLocation> = internalTabClickEvents.asObservable()
private var earlyNavigationListLocationRequested: MainNavigationListLocation? = null
private var earlyNavigationDetailLocationRequested: MainNavigationDetailLocation? = null
var earlyNavigationDetailLocationRequested: MainNavigationDetailLocation? = null
private set
init {
performStoreUpdate(MainNavigationRepository.getNumberOfUnreadMessages()) { unreadChats, state ->
@@ -110,11 +109,13 @@ class MainNavigationViewModel(
goTo(it)
}
earlyNavigationDetailLocationRequested = null
return threePaneScaffoldNavigator
}
fun clearEarlyDetailLocation() {
earlyNavigationDetailLocationRequested = null
}
/**
* Navigates to the requested location. If the navigator is not present, this functionally sets our
* "default" location to that specified, and we will route the user there when the navigator is set.
@@ -130,8 +131,8 @@ class MainNavigationViewModel(
return
}
internalDetailLocation.update {
location
viewModelScope.launch {
internalDetailLocation.emit(location)
}
val focusedPane = when (location) {
@@ -140,12 +141,10 @@ class MainNavigationViewModel(
}
is MainNavigationDetailLocation.Chats.Conversation -> {
latestConversationLocation = location
ThreePaneScaffoldRole.Primary
}
is MainNavigationDetailLocation.Calls -> {
latestCallsLocation = location
ThreePaneScaffoldRole.Primary
}
}
@@ -175,22 +174,10 @@ class MainNavigationViewModel(
}
when (location) {
MainNavigationListLocation.CHATS -> {
internalDetailLocation.update {
latestConversationLocation ?: MainNavigationDetailLocation.Empty
}
}
MainNavigationListLocation.CHATS -> Unit
MainNavigationListLocation.ARCHIVE -> Unit
MainNavigationListLocation.CALLS -> {
internalDetailLocation.update {
latestCallsLocation ?: MainNavigationDetailLocation.Empty
}
}
MainNavigationListLocation.STORIES -> {
internalDetailLocation.update {
MainNavigationDetailLocation.Empty
}
}
MainNavigationListLocation.CALLS -> Unit
MainNavigationListLocation.STORIES -> Unit
}
internalMainNavigationState.update {
@@ -200,11 +187,6 @@ class MainNavigationViewModel(
navigatorScope?.launch {
val currentPane = navigator?.currentDestination?.pane ?: return@launch
if (currentPane == ThreePaneScaffoldRole.Secondary) {
val multiPane = navigator?.scaffoldDirective?.maxHorizontalPartitions == 2
if (multiPane && location == MainNavigationListLocation.CHATS && latestConversationLocation != null) {
navigator?.navigateTo(ThreePaneScaffoldRole.Primary)
}
return@launch
} else {
navigator?.navigateBack()

View File

@@ -5,31 +5,11 @@
package org.thoughtcrime.securesms.main
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
/**
* A navigation host for the stories detail pane of [org.thoughtcrime.securesms.MainActivity].
*
* @param currentDestination The current calls destination to navigate to, containing routing information
* @param contentLayoutData Layout configuration data for responsive UI rendering
*/
@Composable
fun StoriesNavHost(
navHostController: NavHostController,
startDestination: MainNavigationDetailLocation.Stories,
contentLayoutData: MainContentLayoutData
) {
NavHost(
navController = navHostController,
startDestination = startDestination,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } }
) {
fun NavGraphBuilder.storiesNavGraphBuilder() {
composable<MainNavigationDetailLocation.Empty> {
EmptyDetailScreen()
}
}