From 9b517a14cb447eb2b68109f53b9735cc04223a06 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 23 Sep 2025 13:58:34 -0300 Subject: [PATCH] Remove separate controllers and consolidate logic. --- .../thoughtcrime/securesms/MainActivity.kt | 77 ++++++++----- .../links/details/CallLinkDetailsActivity.kt | 2 +- .../links/details/CallLinkDetailsScreen.kt | 2 +- .../securesms/calls/log/CallLogFragment.kt | 2 +- .../components/InsetAwareConstraintLayout.kt | 27 ++++- .../conversation/v2/ConversationFragment.kt | 30 ++++- .../securesms/main/CallsNavHost.kt | 103 ++++-------------- .../securesms/main/ChatsNavHost.kt | 90 +++------------ .../securesms/main/InsetsViewModel.kt | 67 ++++++++++++ .../securesms/main/MainActivityComponents.kt | 60 +++++++--- .../main/MainNavigationDetailLocation.kt | 62 +++++++---- .../securesms/main/MainNavigationViewModel.kt | 48 +++----- .../securesms/main/StoriesNavHost.kt | 30 +---- .../res/layout/v2_conversation_fragment.xml | 2 +- 14 files changed, 320 insertions(+), 282 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/InsetsViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 9c5a9928d5..5fec9349b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -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!!))) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt index c958c9437c..3c1431c9a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt index af595a9b8c..5c8d498776 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index e9015c9a02..9b1749ad7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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)) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt index 284e5174cd..95df6d7675 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index ceafdd59a8..16fa3ef8cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt index 2e2bef6636..69bf630bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt @@ -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 { + 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(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( + typeMap = mapOf( + typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) + ) ) { - composable( - typeMap = mapOf( - typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) - ) - ) { - val route = it.toRoute() + val route = it.toRoute() - LaunchedEffect(route) { - mainNavigationViewModel.goTo(route) - } + CallLinkDetailsScreen(roomId = route.callLinkRoomId) + } - MainActivityDetailContainer(contentLayoutData) { - CallLinkDetailsScreen(roomId = route.callLinkRoomId) - } - } + composable( + typeMap = mapOf( + typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) + ) + ) { + val route = it.toRoute() + val parent = navHostController.previousBackStackEntry ?: return@composable - composable( - typeMap = mapOf( - typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) - ) - ) { - val parent = navHostController.getBackStackEntry(startDestination) - val route = it.toRoute() - - LaunchedEffect(route) { - mainNavigationViewModel.goTo(route) - } - - MainActivityDetailContainer(contentLayoutData) { - CompositionLocalProvider(LocalViewModelStoreOwner provides parent) { - EditCallLinkNameScreen(roomId = route.callLinkRoomId) - } - } + CompositionLocalProvider(LocalViewModelStoreOwner provides parent) { + EditCallLinkNameScreen(roomId = route.callLinkRoomId) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt index b386b259c9..95df8e8d12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt @@ -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 { + 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(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( + typeMap = mapOf( + typeOf() to JsonSerializableNavType(ConversationArgs.serializer()) + ) ) { - composable( - typeMap = mapOf( - typeOf() to JsonSerializableNavType(ConversationArgs.serializer()) - ) - ) { - val route = it.toRoute() - val fragmentState = key(route) { rememberFragmentState() } - val context = LocalContext.current + val route = it.toRoute() + 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() + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/InsetsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/InsetsViewModel.kt new file mode 100644 index 0000000000..3131739eb6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/InsetsViewModel.kt @@ -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 = 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 + ) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt index 02855c1f62..2f9c2d2845 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt @@ -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() - } + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index 38d7f32fcf..a8d8f2f398 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index dfda5eba79..98d15b0696 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -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 = internalDetailLocation + private val internalDetailLocation = MutableSharedFlow() + val detailLocation: SharedFlow = internalDetailLocation val detailLocationObservable: Observable = internalDetailLocation.asObservable() - var latestConversationLocation: MainNavigationDetailLocation.Chats.Conversation? = null - var latestCallsLocation: MainNavigationDetailLocation.Calls? = null private val internalMegaphone = MutableStateFlow(Megaphone.NONE) val megaphone: StateFlow = internalMegaphone @@ -71,7 +69,8 @@ class MainNavigationViewModel( val tabClickEvents: Observable = 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt index 635c2e126a..d369d68735 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt @@ -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 { + EmptyDetailScreen() } } diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 5d4834e9f9..cf525964ce 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -6,7 +6,7 @@ android:layout_height="match_parent" android:clipChildren="false" app:animateKeyboardChanges="true" - app:applyRootInsets="true"> + app:applyRootInsets="false">