Migrate main navigation to Navigation 3.

This commit is contained in:
Alex Hart
2026-09-02 16:11:26 -03:00
parent d57f6b2877
commit 2627ff1b95
98 changed files with 5233 additions and 1712 deletions
@@ -62,10 +62,12 @@ class MainNavigationLaunchTest {
private val recipient: RecipientId get() = harness.others.first()
/**
* Share-target cold-launch regression test. Pre-fix, wrapNavigator() re-routed the
* early-staged Conversation through goTo(), whose async wallpaper-prefetch path emitted
* a SECOND internalDetailLocation with a fresh ConversationArgs — recreating the
* fragment and dropping share data.
* Share-target cold-launch regression test. Originally, replaying a deferred navigation request
* once the navigator went live re-routed the early-staged Conversation through goTo(), whose async
* wallpaper-prefetch path pushed a SECOND entry with a fresh ConversationArgs — recreating the
* fragment and dropping share data. The deferral is gone now that the back stacks are owned by the
* view-model and can be pushed to whether or not a composition is alive, but the double-create this
* guards against is worth keeping a test on.
*/
@Test
fun coldLaunch_shareIntent_createsFragmentExactlyOnceWithShareData() {
@@ -93,9 +95,9 @@ class MainNavigationLaunchTest {
appendLine("--- diagnostic dump ---")
appendLine("fragments observed: ${recorder.allCreated}")
appendLine("activity fragments: ${launched.activity.supportFragmentManager.fragments.map { it::class.simpleName }}")
appendLine("vm.currentListLocation: ${vm.mainNavigationState.value.currentListLocation}")
appendLine("vm.currentListLocation: ${vm.mainNavigationBarState.value.currentListLocation}")
appendLine("vm.detailLocation: ${vm.detailLocation.value}")
appendLine("vm.chatsBackStackEntries: ${vm.chatsBackStackEntries.toList()}")
appendLine("vm.navigator[MainListRoute.Chats]: ${vm.navigator[MainListRoute.Chats].toList()}")
}
}
throw IllegalStateException("${e.message}\n$state", e)
@@ -225,22 +227,22 @@ class MainNavigationLaunchTest {
"Expected shareDataTimestamp=-1 for notification path, got ${args.shareDataTimestamp}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected currentListLocation=CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
check(vm.mainNavigationBarState.value.currentListLocation == MainListRoute.Chats) {
"Expected currentListLocation=CHATS, got ${vm.mainNavigationBarState.value.currentListLocation}"
}
}
}
@Test
fun coldLaunch_tabIntent_setsListLocation() {
val intent = tabIntent(MainNavigationListLocation.CALLS)
val intent = tabIntent(MainListRoute.Calls)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CALLS)
awaitListFragment(launched, MainListRoute.Calls)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
check(vm.mainNavigationBarState.value.currentListLocation == MainListRoute.Calls) {
"Expected VM CALLS, got ${vm.mainNavigationBarState.value.currentListLocation}"
}
Thread.sleep(750)
check(recorder.createdArgs.isEmpty()) {
@@ -256,7 +258,7 @@ class MainNavigationLaunchTest {
*/
@Test
fun coldLaunch_detailLocationIntent_isNoOpToday() {
val intent = detailLocationIntent(MainNavigationDetailLocation.Chats.ConversationSettings(recipient))
val intent = detailLocationIntent(MainDetailRoute.Chats.ConversationSettings(recipient))
launchSync(intent).use { launched ->
val recorder = launched.recorder
Thread.sleep(1500)
@@ -265,7 +267,7 @@ class MainNavigationLaunchTest {
"starts handling it on cold launch, update or delete this test. Got: ${recorder.allCreated}"
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
val staged = runOnMainSync { vm.chatsBackStackEntries.filterNot { it is MainNavigationDetailLocation.Empty } }
val staged = runOnMainSync { vm.navigator[MainListRoute.Chats].filterIsInstance<MainDetailRoute>() }
check(staged.isEmpty()) {
"Expected no detail to be staged on the chats back stack, got $staged"
}
@@ -277,11 +279,11 @@ class MainNavigationLaunchTest {
val intent = deepLinkIntent(Uri.parse("https://signal.org/test-not-a-real-deeplink"))
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
awaitListFragment(launched, MainListRoute.Chats)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS for deep-link launch, got ${vm.mainNavigationState.value.currentListLocation}"
check(vm.mainNavigationBarState.value.currentListLocation == MainListRoute.Chats) {
"Expected CHATS for deep-link launch, got ${vm.mainNavigationBarState.value.currentListLocation}"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for deep-link launch, got ${recorder.createdArgs.size}"
@@ -294,16 +296,16 @@ class MainNavigationLaunchTest {
val intent = Intent(context, MainActivity::class.java)
launchSync(intent).use { launched ->
val recorder = launched.recorder
awaitListFragment(launched, MainNavigationListLocation.CHATS)
awaitListFragment(launched, MainListRoute.Chats)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected default CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
check(vm.mainNavigationBarState.value.currentListLocation == MainListRoute.Chats) {
"Expected default CHATS, got ${vm.mainNavigationBarState.value.currentListLocation}"
}
Thread.sleep(750)
val detailLocation = runOnMainSync { vm.detailLocation.value }
check(detailLocation == MainNavigationDetailLocation.Empty) {
"Expected Empty detail location, got $detailLocation"
check(detailLocation == null) {
"Expected no detail location, got $detailLocation"
}
check(recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for bare launch, got ${recorder.createdArgs.size}"
@@ -353,23 +355,23 @@ class MainNavigationLaunchTest {
}
val baseline = recorder.createdArgs.size
val warmIntent = detailLocationIntent(MainNavigationDetailLocation.Empty)
val warmIntent = MainActivity.clearTopAndExitDetail(context)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
await(description = "no new ConversationFragment after Empty detail intent") {
await(description = "no new ConversationFragment after exit-detail intent") {
recorder.createdArgs.size == baseline
}
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
await(description = "conversation cleared from chats back stack after Empty detail intent") {
vm.chatsBackStackEntries.none { it is MainNavigationDetailLocation.Conversation }
await(description = "conversation cleared from chats back stack after exit-detail intent") {
vm.navigator[MainListRoute.Chats].none { it is MainDetailRoute.Conversation }
}
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CHATS) {
"Expected CHATS, got ${vm.mainNavigationState.value.currentListLocation}"
check(vm.mainNavigationBarState.value.currentListLocation == MainListRoute.Chats) {
"Expected CHATS, got ${vm.mainNavigationBarState.value.currentListLocation}"
}
}
}
@@ -377,18 +379,18 @@ class MainNavigationLaunchTest {
@Test
fun warmStart_onNewIntent_tabIntent_switchesList() {
launchSync(Intent(context, MainActivity::class.java)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CHATS)
awaitListFragment(launched, MainListRoute.Chats)
val warmIntent = tabIntent(MainNavigationListLocation.CALLS)
val warmIntent = tabIntent(MainListRoute.Calls)
runOnMainSync {
InstrumentationRegistry.getInstrumentation().callActivityOnNewIntent(launched.activity, warmIntent)
}
awaitListFragment(launched, MainNavigationListLocation.CALLS)
awaitListFragment(launched, MainListRoute.Calls)
val vm = runOnMainSync { launched.activity.mainNavigationViewModel() }
check(vm.mainNavigationState.value.currentListLocation == MainNavigationListLocation.CALLS) {
"Expected VM CALLS, got ${vm.mainNavigationState.value.currentListLocation}"
check(vm.mainNavigationBarState.value.currentListLocation == MainListRoute.Calls) {
"Expected VM CALLS, got ${vm.mainNavigationBarState.value.currentListLocation}"
}
check(launched.recorder.createdArgs.isEmpty()) {
"Expected no ConversationFragment for tab switch, got ${launched.recorder.createdArgs.size}"
@@ -427,22 +429,22 @@ class MainNavigationLaunchTest {
@Test
fun recreate_midTab_restoresTab() {
launchSync(tabIntent(MainNavigationListLocation.CALLS)).use { launched ->
awaitListFragment(launched, MainNavigationListLocation.CALLS)
launchSync(tabIntent(MainListRoute.Calls)).use { launched ->
awaitListFragment(launched, MainListRoute.Calls)
runOnMainSync { launched.activity.recreate() }
// Verify the user-visible tab content rebinds after recreate, not just the VM. The
// recorder removes destroyed fragments, so this only passes once the post-recreate
// CallLogFragment instance is attached.
awaitListFragment(launched, MainNavigationListLocation.CALLS)
awaitListFragment(launched, MainListRoute.Calls)
// launched.activity returns the *latest* MainActivity (the holder updates in
// onActivityCreated), so this reads the post-recreate VM instance.
val location = runOnMainSync {
launched.activity.mainNavigationViewModel().mainNavigationState.value.currentListLocation
launched.activity.mainNavigationViewModel().mainNavigationBarState.value.currentListLocation
}
check(location == MainNavigationListLocation.CALLS) {
check(location == MainListRoute.Calls) {
"Expected VM CALLS post-recreate, got $location"
}
check(launched.recorder.createdArgs.isEmpty()) {
@@ -573,12 +575,12 @@ class MainNavigationLaunchTest {
}
}
private fun tabIntent(tab: MainNavigationListLocation): Intent {
private fun tabIntent(tab: MainListRoute): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("STARTING_TAB", tab)
}
private fun detailLocationIntent(location: MainNavigationDetailLocation): Intent {
private fun detailLocationIntent(location: MainDetailRoute): Intent {
return Intent(context, MainActivity::class.java)
.putExtra("DETAIL_LOCATION", location)
}
@@ -742,11 +744,11 @@ class MainNavigationLaunchTest {
* attached, so a tab assertion that reads the FragmentManager is a real user-visible
* signal — strictly stronger than reading the VM's `currentListLocation`.
*/
private fun listFragmentClass(location: MainNavigationListLocation): Class<out Fragment> = when (location) {
MainNavigationListLocation.CHATS -> ConversationListFragment::class.java
MainNavigationListLocation.ARCHIVE -> ConversationListArchiveFragment::class.java
MainNavigationListLocation.CALLS -> CallLogFragment::class.java
MainNavigationListLocation.STORIES -> StoriesLandingFragment::class.java
private fun listFragmentClass(location: MainListRoute): Class<out Fragment> = when (location) {
MainListRoute.Chats -> ConversationListFragment::class.java
MainListRoute.Archive -> ConversationListArchiveFragment::class.java
MainListRoute.Calls -> CallLogFragment::class.java
MainListRoute.Stories -> StoriesLandingFragment::class.java
}
/**
@@ -775,7 +777,7 @@ class MainNavigationLaunchTest {
return roots
}
private fun awaitListFragment(launched: LaunchedActivity, location: MainNavigationListLocation) {
private fun awaitListFragment(launched: LaunchedActivity, location: MainListRoute) {
val expected = listFragmentClass(location)
try {
await(timeoutMs = 10_000, description = "${expected.simpleName} attached for $location") {
@@ -9,7 +9,6 @@ import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.MotionEvent
@@ -24,12 +23,12 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
@@ -44,22 +43,18 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole
import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
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.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -68,19 +63,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.subjects.PublishSubject
@@ -92,9 +80,16 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.split.ListDetailNavDisplay
import org.signal.core.ui.compose.split.ListDetailPaneLayout
import org.signal.core.ui.compose.split.ListDetailPaneMetrics
import org.signal.core.ui.compose.split.ListPaneChrome
import org.signal.core.ui.compose.split.PaneAnchor
import org.signal.core.ui.compose.split.rememberListDetailPaneLayout
import org.signal.core.ui.compose.split.rememberListDetailPaneMetrics
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.navigation.TransitionSpecs
import org.signal.core.ui.permissions.Permissions
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.AppForegroundObserver
@@ -109,14 +104,12 @@ import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgressState
import org.thoughtcrime.securesms.backup.v2.ui.CouldNotCompleteBackupRestoreSheet
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.callsNavEntries
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.calls.quality.CallQuality
import org.thoughtcrime.securesms.calls.quality.CallQualityBottomSheetFragment
import org.thoughtcrime.securesms.chats.ConversationTransitionState
import org.thoughtcrime.securesms.chats.chatsNavEntries
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.compose.ConnectivityWarningBottomSheet
@@ -135,7 +128,6 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.NewConversationActivity
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog
@@ -145,14 +137,14 @@ 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.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
import org.thoughtcrime.securesms.main.MainContentLayoutData
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainMegaphoneState
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
@@ -164,6 +156,7 @@ import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarState
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.rememberDecoratedDetailEntries
import org.thoughtcrime.securesms.mediasend.MediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
@@ -177,8 +170,6 @@ import org.thoughtcrime.securesms.service.BackupMediaRestoreService
import org.thoughtcrime.securesms.service.KeyCachingService
import org.thoughtcrime.securesms.starred.StarredMessagesActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.stories.storiesNavEntries
import org.thoughtcrime.securesms.util.AppStartup
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.CommunicationActions
@@ -187,12 +178,6 @@ import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SplashScreenUtil
import org.thoughtcrime.securesms.util.TopToastPopup
import org.thoughtcrime.securesms.util.viewModel
import org.thoughtcrime.securesms.window.AppPaneDragHandle
import org.thoughtcrime.securesms.window.AppScaffold
import org.thoughtcrime.securesms.window.AppScaffoldAnimationStateFactory
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import org.thoughtcrime.securesms.window.NavigationType
import org.thoughtcrime.securesms.window.rememberThreePaneScaffoldNavigatorDelegate
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
import kotlin.time.Duration.Companion.minutes
import org.signal.core.ui.R as CoreUiR
@@ -212,7 +197,11 @@ class MainActivity :
private const val KEY_STARTING_TAB = "STARTING_TAB"
private const val KEY_DETAIL_LOCATION = "DETAIL_LOCATION"
const val RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901
private const val KEY_EXIT_DETAIL = "EXIT_DETAIL"
const val RESULT_CONFIG_CHANGED = RESULT_FIRST_USER + 901
/** Width the navigation rail occupies inside the list pane. */
private val RAIL_WIDTH = 80.dp
@JvmStatic
fun clearTop(context: Context): Intent {
@@ -221,14 +210,23 @@ class MainActivity :
}
@JvmStatic
fun clearTopAndOpenTab(context: Context, startingTab: MainNavigationListLocation): Intent {
fun clearTopAndOpenTab(context: Context, startingTab: MainListRoute): Intent {
return clearTop(context).putExtra(KEY_STARTING_TAB, startingTab)
}
@JvmStatic
fun clearTopAndOpenDetail(context: Context, location: MainNavigationDetailLocation): Intent {
fun clearTopAndOpenDetail(context: Context, location: MainDetailRoute): Intent {
return clearTop(context).putExtra(KEY_DETAIL_LOCATION, location)
}
/**
* Opens the main screen with the current tab's detail content dropped, leaving its list displayed.
* Used by screens that finish having invalidated whatever the detail pane was showing.
*/
@JvmStatic
fun clearTopAndExitDetail(context: Context): Intent {
return clearTop(context).putExtra(KEY_EXIT_DETAIL, true)
}
}
private val dynamicTheme = DynamicNoActionBarTheme()
@@ -241,8 +239,8 @@ class MainActivity :
get() = mediaController
private val mainNavigationViewModel: MainNavigationViewModel by viewModel {
val startingTab = intent.extras?.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
MainNavigationViewModel(it.createSavedStateHandle(), startingTab ?: MainNavigationListLocation.CHATS)
val startingTab = intent.extras?.getSerializableCompat(KEY_STARTING_TAB, MainListRoute::class.java)
MainNavigationViewModel(it.createSavedStateHandle(), startingTab ?: MainListRoute.Chats)
}
private val vitalsViewModel: VitalsViewModel by viewModel {
@@ -303,7 +301,7 @@ class MainActivity :
mainNavigationViewModel.navigationEvents.collectLatest {
when (it) {
MainNavigationViewModel.NavigationEvent.STORY_CAMERA_FIRST -> {
mainBottomChromeCallback.onCameraClick(MainNavigationListLocation.STORIES)
mainBottomChromeCallback.onCameraClick(MainListRoute.Stories)
}
}
}
@@ -383,27 +381,23 @@ class MainActivity :
setContent {
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
val mainNavigationState by mainNavigationViewModel.mainNavigationState.collectAsStateWithLifecycle()
val mainNavigationState by mainNavigationViewModel.mainNavigationBarState.collectAsStateWithLifecycle()
LaunchedEffect(mainNavigationState.currentListLocation) {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS -> toolbarViewModel.presentToolbarForConversationListFragment()
MainNavigationListLocation.ARCHIVE -> toolbarViewModel.presentToolbarForConversationListArchiveFragment()
MainNavigationListLocation.CALLS -> toolbarViewModel.presentToolbarForCallLogFragment()
MainNavigationListLocation.STORIES -> toolbarViewModel.presentToolbarForStoriesLandingFragment()
MainListRoute.Chats -> toolbarViewModel.presentToolbarForConversationListFragment()
MainListRoute.Archive -> toolbarViewModel.presentToolbarForConversationListArchiveFragment()
MainListRoute.Calls -> toolbarViewModel.presentToolbarForCallLogFragment()
MainListRoute.Stories -> toolbarViewModel.presentToolbarForStoriesLandingFragment()
}
}
val isActionModeActive = mainToolbarState.mode == MainToolbarMode.ACTION_MODE
val isSearchModeActive = mainToolbarState.mode == MainToolbarMode.SEARCH
val isNavigationRailVisible = mainToolbarState.mode != MainToolbarMode.SEARCH
val isNavigationBarVisible = mainToolbarState.mode == MainToolbarMode.FULL
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS && !isActionModeActive && !isSearchModeActive
val isBackHandlerEnabled = mainToolbarState.destination != MainListRoute.Chats && !isActionModeActive && !isSearchModeActive
BackHandler(enabled = isBackHandlerEnabled) {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainListRoute.Chats)
}
BackHandler(enabled = isActionModeActive) {
@@ -421,35 +415,18 @@ class MainActivity :
}
}
val mainBottomChromeState = remember(mainToolbarState.destination, mainToolbarState.mode, megaphone) {
MainBottomChromeState(
destination = mainToolbarState.destination,
mainToolbarMode = mainToolbarState.mode,
megaphoneState = MainMegaphoneState(
megaphone = megaphone,
mainToolbarMode = mainToolbarState.mode
)
)
}
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val contentLayoutData = MainContentLayoutData.rememberContentLayoutData(mainToolbarState.mode)
val contentLayoutData = rememberListDetailPaneMetrics(listPaddingStart = mainToolbarState.mode.listPaddingStart)
MainContainer {
val wrappedNavigator = rememberNavigator(isSplitPane, contentLayoutData, maxWidth)
val listPaneWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
val navigationType = NavigationType.rememberNavigationType()
val detailLocation by mainNavigationViewModel.detailLocation.collectAsStateWithLifecycle()
val isConversationFullscreen = !isSplitPane &&
wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded &&
detailLocation is MainNavigationDetailLocation.Conversation
val isConversationFullscreen = !isSplitPane && detailLocation is MainDetailRoute.Conversation
val context = LocalContext.current
val isDarkTheme = isSystemInDarkTheme()
val navBarColor = when {
isSplitPane -> SignalTheme.colors.colorSurface1.toArgb()
isConversationFullscreen -> Color.TRANSPARENT
isConversationFullscreen -> Color.Transparent.toArgb()
else -> ContextCompat.getColor(context, CoreUiR.color.signal_colorSurface2)
}
@@ -467,125 +444,40 @@ class MainActivity :
}
}
val anchors = remember(contentLayoutData, mainToolbarState, listPaneWidth, navigationType) {
val halfPartitionWidth = contentLayoutData.partitionWidth / 2
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
val detailOffset = when {
mainToolbarState.mode == MainToolbarMode.SEARCH -> 0.dp
navigationType == NavigationType.BAR -> 0.dp
else -> 80.dp
}
val detailOnlyAnchor = PaneExpansionAnchor.Offset.fromStart(detailOffset + contentLayoutData.listPaddingStart + halfPartitionWidth)
val detailAndListAnchor = PaneExpansionAnchor.Offset.fromStart(listPaneWidth + halfPartitionWidth)
val listOnlyAnchor = PaneExpansionAnchor.Offset.fromEnd(contentLayoutData.detailPaddingEnd - halfPartitionWidth)
listOf(detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor)
DisposableEffect(convoTransitionState) {
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
onDispose { mainNavigationViewModel.setChatListSnapshotCaptureProvider(null) }
}
val (detailOnlyAnchor, detailAndListAnchor, listOnlyAnchor) = anchors
val paneAnchor by mainNavigationViewModel.paneAnchor.collectAsStateWithLifecycle()
val hasDetailContent by mainNavigationViewModel.hasDetailContent.collectAsStateWithLifecycle()
val paneExpansionState = rememberPaneExpansionState(
key = wrappedNavigator.scaffoldValue.paneExpansionStateKey,
anchors = anchors,
initialAnchoredIndex = 1
val tabEntries = rememberDecoratedDetailEntries(mainNavigationViewModel, convoTransitionState, isSplitPane)
val paneLayout = rememberMainPaneLayout(
contentLayoutData = contentLayoutData,
maxWidth = maxWidth,
toolbarMode = mainToolbarState.mode,
paneAnchor = paneAnchor
)
val paneAnchorIndex = rememberSaveable(paneExpansionState.currentAnchor) {
anchors.indexOf(paneExpansionState.currentAnchor)
val listPaneChrome: ListPaneChrome = remember {
{ content -> MainListPaneChrome(content = content) }
}
LaunchedEffect(anchors) {
val index = when {
paneAnchorIndex < 0 -> 1
paneAnchorIndex > anchors.lastIndex -> anchors.lastIndex
else -> paneAnchorIndex
}
if (index in anchors.indices) {
val anchor = anchors[index]
paneExpansionState.animateTo(anchor)
}
val emptyDetailContent: @Composable () -> Unit = remember {
{ EmptyDetailScreen() }
}
val convoTransitionState = ConversationTransitionState.remember(isSplitPane)
val mutableInteractionSource = remember { MutableInteractionSource() }
LaunchedEffect(convoTransitionState) {
mainNavigationViewModel.setChatListSnapshotCaptureProvider { convoTransitionState.writeGraphicsLayerToBitmap() }
}
LaunchedEffect(isSplitPane) {
mainNavigationViewModel.onSplitPaneChanged(isSplitPane)
}
val scope = rememberCoroutineScope()
BackHandler(paneExpansionState.currentAnchor == detailOnlyAnchor) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Empty)
scope.launch {
paneExpansionState.animateTo(listOnlyAnchor)
}
}
LaunchedEffect(paneExpansionState.currentAnchor, detailOnlyAnchor, listOnlyAnchor, detailAndListAnchor) {
val isFullScreenPane = when (paneExpansionState.currentAnchor) {
listOnlyAnchor, detailOnlyAnchor -> {
true
}
else -> {
false
}
}
mainNavigationViewModel.onPaneAnchorChanged(isFullScreenPane)
}
LaunchedEffect(paneExpansionState.currentAnchor) {
when (paneExpansionState.currentAnchor) {
listOnlyAnchor -> {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Secondary)
}
detailOnlyAnchor -> {
mainNavigationViewModel.setFocusedPane(ThreePaneScaffoldRole.Primary)
}
else -> Unit
}
}
val paneFocusRequest by mainNavigationViewModel.paneFocusRequests.collectAsStateWithLifecycle(null)
LaunchedEffect(paneFocusRequest) {
if (paneFocusRequest == null) {
return@LaunchedEffect
}
if (paneFocusRequest == ThreePaneScaffoldRole.Secondary && paneExpansionState.currentAnchor == detailOnlyAnchor) {
paneExpansionState.animateTo(listOnlyAnchor)
}
if (paneFocusRequest == ThreePaneScaffoldRole.Primary && paneExpansionState.currentAnchor == listOnlyAnchor) {
paneExpansionState.animateTo(detailOnlyAnchor)
}
}
val noEnterTransitionFactory = remember {
AppScaffoldAnimationStateFactory(
enabledStates = AppScaffoldNavigator.NavigationState.entries.filterNot {
it == AppScaffoldNavigator.NavigationState.ENTER
}.toSet()
)
}
AppScaffold(
navigator = wrappedNavigator,
modifier = convoTransitionState.writeContentToGraphicsLayer(),
paneExpansionState = paneExpansionState,
Scaffold(
containerColor = Color.Transparent,
contentWindowInsets = WindowInsets(),
snackbarHost = {
if (wrappedNavigator.scaffoldValue.primary == PaneAdaptedValue.Expanded) {
// MainBottomChrome renders its own host over the list, but only in single pane, so this one
// has to cover both split pane and whatever fills the window in single pane.
if (isSplitPane || hasDetailContent) {
MainSnackbar(
hostKey = SnackbarHostKey.Global,
onDismissed = mainBottomChromeCallback::onSnackbarDismissed,
@@ -593,170 +485,20 @@ class MainActivity :
)
}
},
bottomNavContent = {
if (isNavigationBarVisible) {
Column(
modifier = Modifier
.clip(contentLayoutData.navigationBarShape)
.background(color = SignalTheme.colors.colorSurface2)
) {
MainNavigationBar(
state = mainNavigationState,
onDestinationSelected = mainNavigationCallback
)
if (!LocalResources.current.rememberIsSplitPane()) {
Spacer(Modifier.navigationBarsPadding())
}
}
}
},
navRailContent = {
if (isNavigationRailVisible) {
MainNavigationRail(
state = mainNavigationState,
mainFloatingActionButtonsCallback = mainBottomChromeCallback,
onDestinationSelected = mainNavigationCallback
)
}
},
secondaryContent = {
val listContainerColor = if (isSplitPane) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
}
Column(
modifier = Modifier
.padding(start = contentLayoutData.listPaddingStart)
.fillMaxSize()
.background(listContainerColor, contentLayoutData.shape)
.clip(contentLayoutData.shape)
) {
MainToolbar(
state = mainToolbarState,
callback = toolbarCallback
)
Box(
modifier = Modifier.weight(1f)
) {
when (val destination = mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = ConversationListFragment::class.java,
fragmentState = state,
modifier = Modifier.fillMaxSize()
)
}
MainNavigationListLocation.ARCHIVE -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = ConversationListArchiveFragment::class.java,
fragmentState = state,
modifier = Modifier.fillMaxSize()
)
}
MainNavigationListLocation.CALLS -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = CallLogFragment::class.java,
fragmentState = state,
modifier = Modifier.fillMaxSize()
)
}
MainNavigationListLocation.STORIES -> {
val state = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = StoriesLandingFragment::class.java,
fragmentState = state,
modifier = Modifier.fillMaxSize()
)
}
}
MainBottomChrome(
state = mainBottomChromeState,
callback = mainBottomChromeCallback,
megaphoneActionController = megaphoneActionController,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
},
primaryContent = {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
when (mainNavigationState.currentListLocation) {
MainNavigationListLocation.CHATS, MainNavigationListLocation.ARCHIVE -> {
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.chatsBackStackEntries,
onBack = { mainNavigationViewModel.popChatsDetailLocation() },
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryProvider = entryProvider { chatsNavEntries(convoTransitionState) }
)
}
MainNavigationListLocation.CALLS -> {
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.callsBackStackEntries,
onBack = { mainNavigationViewModel.popCallsDetailLocation() },
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { callsNavEntries(isSplitPane) }
)
}
MainNavigationListLocation.STORIES -> {
NavDisplay<NavKey>(
backStack = mainNavigationViewModel.storiesBackStackEntries,
onBack = { mainNavigationViewModel.popStoriesDetailLocation() },
transitionSpec = { TransitionSpecs.HorizontalSlide.transitionSpec },
popTransitionSpec = { TransitionSpecs.HorizontalSlide.popTransitionSpec },
predictivePopTransitionSpec = { TransitionSpecs.HorizontalSlide.predictivePopTransitionSpec },
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider { storiesNavEntries() }
)
}
}
}
},
paneExpansionDragHandle = if (contentLayoutData.hasDragHandle()) {
{
AppPaneDragHandle(
paneExpansionState = paneExpansionState,
mutableInteractionSource = mutableInteractionSource
)
}
} else {
null
},
animatorFactory = if (mainNavigationState.currentListLocation.isChatsTab) {
noEnterTransitionFactory
} else {
AppScaffoldAnimationStateFactory.Default
}
)
modifier = convoTransitionState.writeContentToGraphicsLayer()
) { paddingValues ->
ListDetailNavDisplay(
entries = tabEntries,
isSplitPane = isSplitPane,
paneAnchor = paneAnchor,
onBack = { mainNavigationViewModel.popCurrentDetailLocation() },
onExitDetail = { mainNavigationViewModel.exitDetailLocation() },
layout = paneLayout,
listPaneChrome = listPaneChrome,
emptyDetailContent = emptyDetailContent,
modifier = Modifier.padding(paddingValues)
)
}
}
}
@@ -784,26 +526,118 @@ class MainActivity :
}
/**
* Creates and wraps a scaffold navigator such that we can use it to operate with both
* our split pane and legacy activities.
* Builds the geometry for the list/detail split and keeps it following the view-model's anchor.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun rememberNavigator(
isSplitPane: Boolean,
contentLayoutData: MainContentLayoutData,
maxWidth: Dp
): AppScaffoldNavigator<Any> {
val scaffoldNavigator = rememberThreePaneScaffoldNavigatorDelegate(
isSplitPane = isSplitPane,
horizontalPartitionSpacerSize = contentLayoutData.partitionWidth,
defaultPanePreferredWidth = contentLayoutData.rememberDefaultPanePreferredWidth(maxWidth)
private fun rememberMainPaneLayout(
contentLayoutData: ListDetailPaneMetrics,
maxWidth: Dp,
toolbarMode: MainToolbarMode,
paneAnchor: PaneAnchor
): ListDetailPaneLayout {
val navigationType = NavigationType.rememberNavigationType()
return rememberListDetailPaneLayout(
paneAnchor = paneAnchor,
maxWidth = maxWidth,
onAnchorSelected = { mainNavigationViewModel.onPaneAnchorSelected(it) },
metrics = contentLayoutData,
// Searching hides the rail, leaving nothing of the list pane behind once the detail fills the window.
collapsedListWidth = when {
toolbarMode == MainToolbarMode.SEARCH -> 0.dp
navigationType == NavigationType.BAR -> 0.dp
else -> RAIL_WIDTH
}
)
}
val coroutine = rememberCoroutineScope()
/**
* The chrome belonging to the list pane — navigation rail or bar, toolbar, and the floating buttons and
* megaphones layered over the list — wrapped around [content].
*
* Handed to [ListDetailNavDisplay] as a [ListPaneChrome] rather than as a scene parameter: a scene excludes
* its content lambda from equality, so anything captured there would go stale when an equal instance is
* retained.
*/
@Composable
private fun MainListPaneChrome(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle()
val mainNavigationState by mainNavigationViewModel.mainNavigationBarState.collectAsStateWithLifecycle()
val megaphone by mainNavigationViewModel.megaphone.collectAsStateWithLifecycle()
return remember(scaffoldNavigator, coroutine) {
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator)
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val contentLayoutData = rememberListDetailPaneMetrics(listPaddingStart = mainToolbarState.mode.listPaddingStart)
val navigationType = NavigationType.rememberNavigationType()
val bottomChromeState = remember(mainToolbarState.destination, mainToolbarState.mode, megaphone) {
MainBottomChromeState(
destination = mainToolbarState.destination,
mainToolbarMode = mainToolbarState.mode,
megaphoneState = MainMegaphoneState(
megaphone = megaphone,
mainToolbarMode = mainToolbarState.mode
)
)
}
val listContainerColor = if (isSplitPane) {
SignalTheme.colors.colorSurface1
} else {
MaterialTheme.colorScheme.surface
}
Row(modifier = modifier.fillMaxSize()) {
if (navigationType == NavigationType.RAIL && mainToolbarState.mode != MainToolbarMode.SEARCH) {
MainNavigationRail(
state = mainNavigationState,
mainFloatingActionButtonsCallback = mainBottomChromeCallback,
onDestinationSelected = mainNavigationCallback
)
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.background(listContainerColor, contentLayoutData.shape)
.clip(contentLayoutData.shape)
) {
MainToolbar(
state = mainToolbarState,
callback = toolbarCallback
)
Box(modifier = Modifier.weight(1f)) {
content()
MainBottomChrome(
state = bottomChromeState,
callback = mainBottomChromeCallback,
megaphoneActionController = megaphoneActionController,
modifier = Modifier.align(Alignment.BottomCenter)
)
}
if (navigationType == NavigationType.BAR && mainToolbarState.mode == MainToolbarMode.FULL) {
Column(
modifier = Modifier
.clip(contentLayoutData.navigationBarShape)
.background(color = SignalTheme.colors.colorSurface2)
) {
MainNavigationBar(
state = mainNavigationState,
onDestinationSelected = mainNavigationCallback
)
if (!isSplitPane) {
Spacer(Modifier.navigationBarsPadding())
}
}
}
}
}
}
@@ -855,19 +689,24 @@ class MainActivity :
val extras = intent.extras ?: return
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainNavigationDetailLocation::class.java)
if (extras.getBoolean(KEY_EXIT_DETAIL, false)) {
mainNavigationViewModel.exitDetailLocation()
return
}
val detailLocation = extras.getParcelableCompat(KEY_DETAIL_LOCATION, MainDetailRoute::class.java)
if (detailLocation != null) {
goTo(detailLocation)
return
}
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainNavigationListLocation::class.java)
val startingTab = extras.getSerializableCompat(KEY_STARTING_TAB, MainListRoute::class.java)
when (startingTab) {
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
MainNavigationListLocation.ARCHIVE -> mainNavigationViewModel.onArchiveSelected()
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
MainNavigationListLocation.STORIES -> {
MainListRoute.Chats -> mainNavigationViewModel.onChatsSelected()
MainListRoute.Archive -> mainNavigationViewModel.onArchiveSelected()
MainListRoute.Calls -> mainNavigationViewModel.onCallsSelected()
MainListRoute.Stories -> {
if (Stories.isFeatureEnabled()) {
mainNavigationViewModel.onStoriesSelected()
}
@@ -1057,8 +896,8 @@ class MainActivity :
return
}
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(ConversationIntents.readArgsFromBundle(extras)))
mainNavigationViewModel.goTo(MainListRoute.Chats)
mainNavigationViewModel.goTo(MainDetailRoute.Conversation(ConversationIntents.readArgsFromBundle(extras)))
intent.action = null
setIntent(intent)
}
@@ -1109,7 +948,7 @@ class MainActivity :
private fun handleQuickRestoreIntent(intent: Intent) {
intent.data?.let { data ->
CommunicationActions.handlePotentialQuickRestoreUrl(this, data.toString()) {
onCameraClick(MainNavigationListLocation.CHATS, isForQuickRestore = true)
onCameraClick(MainListRoute.Chats, isForQuickRestore = true)
}
}
}
@@ -1152,7 +991,7 @@ class MainActivity :
}
}
private fun onCameraClick(destination: MainNavigationListLocation, isForQuickRestore: Boolean) {
private fun onCameraClick(destination: MainListRoute, isForQuickRestore: Boolean) {
val onGranted = {
if (isForQuickRestore) {
startActivity(MediaSendLauncher.cameraForQuickRestore(context = this@MainActivity))
@@ -1160,7 +999,7 @@ class MainActivity :
startActivity(
MediaSendLauncher.camera(
context = this@MainActivity,
isStory = destination == MainNavigationListLocation.STORIES
isStory = destination == MainListRoute.Stories
)
)
}
@@ -1230,11 +1069,11 @@ class MainActivity :
}
override fun onStoryPrivacyClick() {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.PrivacySettings)
mainNavigationViewModel.goTo(MainDetailRoute.Stories.PrivacySettings)
}
override fun onStoryArchiveClick() {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.Archive)
mainNavigationViewModel.goTo(MainDetailRoute.Stories.Archive)
}
override fun onCloseSearchClick() {
@@ -1281,7 +1120,7 @@ class MainActivity :
startActivity(NewCallActivity.createIntent(this@MainActivity))
}
override fun onCameraClick(destination: MainNavigationListLocation) {
override fun onCameraClick(destination: MainListRoute) {
onCameraClick(destination, false)
}
@@ -1327,17 +1166,18 @@ class MainActivity :
}
}
private inner class MainNavigationCallback : (MainNavigationListLocation) -> Unit {
override fun invoke(location: MainNavigationListLocation) {
private inner class MainNavigationCallback : (MainListRoute) -> Unit {
override fun invoke(location: MainListRoute) {
when (location) {
MainNavigationListLocation.CHATS -> mainNavigationViewModel.onChatsSelected()
MainNavigationListLocation.CALLS -> mainNavigationViewModel.onCallsSelected()
MainNavigationListLocation.STORIES -> mainNavigationViewModel.onStoriesSelected()
MainNavigationListLocation.ARCHIVE -> mainNavigationViewModel.onArchiveSelected()
MainListRoute.Chats -> mainNavigationViewModel.onChatsSelected()
MainListRoute.Calls -> mainNavigationViewModel.onCallsSelected()
MainListRoute.Stories -> mainNavigationViewModel.onStoriesSelected()
MainListRoute.Archive -> mainNavigationViewModel.onArchiveSelected()
}
}
}
override fun goTo(location: MainNavigationListLocation) = mainNavigationViewModel.goTo(location)
override fun goTo(location: MainNavigationDetailLocation) = mainNavigationViewModel.goTo(location)
override fun goTo(location: MainListRoute) = mainNavigationViewModel.goTo(location)
override fun goTo(location: MainDetailRoute) = mainNavigationViewModel.goTo(location)
override fun exitDetailLocation() = mainNavigationViewModel.exitDetailLocation()
}
@@ -10,7 +10,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation;
import org.thoughtcrime.securesms.main.MainDetailRoute;
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -50,7 +50,7 @@ public class MainNavigator {
.withStartingPosition(startingPosition)
.asIncognito(incognito)
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(args)));
.subscribe(args -> viewModel.goTo(new MainDetailRoute.Conversation(args)));
lifecycleDisposable.add(disposable);
}
@@ -1,64 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
/**
* Controls the navigation stack used by the calls screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class CallsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "calls_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeCallId: CallLogRow.Id?
get() = entries.asReversed().firstNotNullOfOrNull { location ->
when (location) {
is MainNavigationDetailLocation.Calls -> location.controllerKey
is MainNavigationDetailLocation.CallLinkDetails -> location.controllerKey
else -> null
}
}
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
when {
location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit
location.isContentRoot -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -8,45 +8,55 @@ package org.thoughtcrime.securesms.calls
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.signal.core.ui.compose.split.detailEntry
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.main.MainDetailRoute
fun EntryProviderScope<NavKey>.callsNavEntries(isSplitPane: Boolean) {
entry<MainNavigationDetailLocation.Empty> {
NoCallSelectedEntry()
}
entry<MainNavigationDetailLocation.CallLinkDetails>(
/**
* Registers the routes utilized for the main screen calls tab.
*/
fun EntryProviderScope<NavKey>.registerCallsTabDetailRoutes(isSplitPane: Boolean) {
detailEntry<MainDetailRoute.CallLinkDetails>(
metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap()
) { route ->
CallLinkDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName> { route ->
detailEntry<MainDetailRoute.Calls.CallLinks.EditCallLinkName> { route ->
EditCallLinkNameEntry(route)
}
}
/**
* List pane content for the calls tab.
*/
@Composable
private fun NoCallSelectedEntry() {
EmptyDetailScreen()
fun CallsListPane(modifier: Modifier = Modifier) {
AndroidFragment(
clazz = CallLogFragment::class.java,
fragmentState = rememberFragmentState(),
modifier = modifier
)
}
@Composable
private fun CallLinkDetailsEntry(route: MainNavigationDetailLocation.CallLinkDetails) {
private fun CallLinkDetailsEntry(route: MainDetailRoute.CallLinkDetails) {
informNavigatorWeAreReady()
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
@Composable
private fun EditCallLinkNameEntry(route: MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName) {
private fun EditCallLinkNameEntry(route: MainDetailRoute.Calls.CallLinks.EditCallLinkName) {
informNavigatorWeAreReady()
EditCallLinkNameScreen(
@@ -46,8 +46,8 @@ import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlrea
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainNavigationCallDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
@@ -114,7 +114,7 @@ class DefaultCallLinkDetailsCallback(
override fun onEditNameClicked() {
router.goToCallDetail(
MainNavigationDetailLocation.Calls.CallLinks.EditCallLinkName(
MainDetailRoute.Calls.CallLinks.EditCallLinkName(
callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId(),
currentName = viewModel.nameSnapshot
)
@@ -49,8 +49,8 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.main.MainToolbarMode
@@ -210,7 +210,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) {
disposables += mainNavigationViewModel.tabClickEventsObservable
.filter { it == MainNavigationListLocation.CALLS }
.filter { it == MainListRoute.Calls }
.subscribeBy(onNext = {
scrollToPositionDelegate.resetScrollPosition()
})
@@ -335,7 +335,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.CallLinkDetails(callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainDetailRoute.CallLinkDetails(callLogRow.record.roomId))
}
}
@@ -390,7 +390,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
override fun goToCallLinkDetails(roomId: CallLinkRoomId) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.CallLinkDetails(roomId))
mainNavigationViewModel.goTo(MainDetailRoute.CallLinkDetails(roomId))
}
override fun deleteCall(call: CallLogRow) {
@@ -137,7 +137,6 @@ private fun NewCallScreenUi(
RecipientPickerScaffold(
title = stringResource(R.string.NewCallActivity__new_call),
forceSplitPane = uiState.forceSplitPane,
onNavigateUpClick = callbacks::onBackPressed,
topAppBarActions = { TopAppBarActions(callbacks) },
snackbarHostState = snackbarHostState,
@@ -245,9 +244,7 @@ private fun UserMessagesHost(
private fun NewCallScreenPreview() {
Previews.Preview {
NewCallScreenUi(
uiState = NewCallUiState(
forceSplitPane = false
),
uiState = NewCallUiState(),
callbacks = NewCallUiCallbacks.Empty
)
}
@@ -151,7 +151,6 @@ class NewCallViewModel : ViewModel() {
}
data class NewCallUiState(
val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane,
val searchQuery: String = "",
val isLookingUpRecipient: Boolean = false,
val isRefreshingContacts: Boolean = false,
@@ -1,64 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.chats
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Controls the navigation stack used by the chats screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class ChatsBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "chats_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
val activeRecipientId: RecipientId?
get() = entries.asReversed().firstNotNullOfOrNull {
when (it) {
is MainNavigationDetailLocation.Conversation -> it.conversationArgs.recipientId
is MainNavigationDetailLocation.Chats -> it.controllerKey
else -> null
}
}
/**
* Pushes an entry onto the stack.
*/
override fun push(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Empty, entries.lastOrNull() -> Unit
is MainNavigationDetailLocation.Conversation -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -27,6 +27,7 @@ import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.split.detailEntry
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsNavHostFragment
@@ -35,43 +36,62 @@ import org.thoughtcrime.securesms.compose.FragmentBackHandler
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
fun EntryProviderScope<NavKey>.chatsNavEntries(
/**
* Registers the routes available in the chats tab of the main screen.
*/
fun EntryProviderScope<NavKey>.registerChatsTabDetailRoutes(
transitionState: ConversationTransitionState
) {
entry<MainNavigationDetailLocation.Empty> {
NoConvoSelectedEntry()
}
entry<MainNavigationDetailLocation.Conversation>(
// disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode.
metadata = TransitionSpecs.None.metadata
detailEntry<MainDetailRoute.Conversation>(
// Since we can't do delayed entry transitions yet, we disable this one and fake our own with the transition state.
metadata = TransitionSpecs.suppressEnterMetadata
) { route ->
ConversationEntry(route, transitionState)
}
entry<MainNavigationDetailLocation.Chats.MessageDetails> { route ->
detailEntry<MainDetailRoute.Chats.MessageDetails> { route ->
MessageDetailsEntry(route)
}
entry<MainNavigationDetailLocation.Chats.ConversationSettings>(
detailEntry<MainDetailRoute.Chats.ConversationSettings>(
metadata = TransitionSpecs.FadeScale.metadata
) { route ->
ConversationSettingsEntry(route)
}
}
/**
* List pane content for the chats tab.
*/
@Composable
private fun NoConvoSelectedEntry() {
EmptyDetailScreen()
fun ChatsListPane(modifier: Modifier = Modifier) {
AndroidFragment(
clazz = ConversationListFragment::class.java,
fragmentState = rememberFragmentState(),
modifier = modifier
)
}
/**
* List pane content for the archive, which is displayed within the chats tab.
*/
@Composable
fun ArchiveListPane(modifier: Modifier = Modifier) {
AndroidFragment(
clazz = ConversationListArchiveFragment::class.java,
fragmentState = rememberFragmentState(),
modifier = modifier
)
}
@Composable
private fun ConversationEntry(
route: MainNavigationDetailLocation.Conversation,
route: MainDetailRoute.Conversation,
transitionState: ConversationTransitionState
) {
val context = LocalContext.current
@@ -110,7 +130,7 @@ private fun ConversationEntry(
}
@Composable
private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.MessageDetails) {
private fun MessageDetailsEntry(route: MainDetailRoute.Chats.MessageDetails) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
@@ -129,7 +149,7 @@ private fun MessageDetailsEntry(route: MainNavigationDetailLocation.Chats.Messag
}
@Composable
private fun ConversationSettingsEntry(route: MainNavigationDetailLocation.Chats.ConversationSettings) {
private fun ConversationSettingsEntry(route: MainDetailRoute.Chats.ConversationSettings) {
val navigatorProvider = LocalContext.current as? MainNavigator.NavigatorProvider
val fragmentState = key(route) { rememberFragmentState() }
val arguments: Bundle? by produceState(null, route.recipientId) {
@@ -324,23 +324,6 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
sectionHeaderPref(DSLSettingsText.from("App UI"))
switchPref(
title = DSLSettingsText.from("Force split pane UI on phones."),
isEnabled = !state.forceSinglePane,
isChecked = state.forceSplitPane,
onClick = {
viewModel.setForceSplitPane(!state.forceSplitPane)
}
)
switchPref(
title = DSLSettingsText.from("Force single-pane on newer devices."),
isChecked = state.forceSinglePane,
onClick = {
viewModel.setForceSinglePane(!state.forceSinglePane)
}
)
clickPref(
title = DSLSettingsText.from("Display enable permission sheet"),
onClick = {
@@ -35,8 +35,6 @@ data class InternalSettingsState(
val pnpInitialized: Boolean,
val useConversationItemV2ForMedia: Boolean,
val hasPendingOneTimeDonation: Boolean,
val forceSplitPane: Boolean,
val forceSinglePane: Boolean,
val disableInternalUser: Boolean,
val searchQuery: String = ""
)
@@ -274,8 +274,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
pnpInitialized = SignalStore.misc.hasPniInitializedDevices,
useConversationItemV2ForMedia = SignalStore.internal.useConversationItemV2Media,
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
forceSplitPane = SignalStore.internal.forceSplitPane,
forceSinglePane = SignalStore.internal.forceSinglePane,
disableInternalUser = RemoteConfig.internalUserDisabled
)
@@ -292,16 +290,6 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setForceSplitPane(forceSplitPane: Boolean) {
SignalStore.internal.forceSplitPane = forceSplitPane
refresh()
}
fun setForceSinglePane(forceSinglePane: Boolean) {
SignalStore.internal.forceSinglePane = forceSinglePane
refresh()
}
private fun buildSearchGroups(items: MappingModelList): List<SearchGroup> {
val groups = mutableListOf<SearchGroup>()
var divider: DividerPreference? = null
@@ -65,7 +65,6 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentD
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache
@@ -562,7 +561,7 @@ class ConversationSettingsFragment : ComposeFragment() {
if (chatRouter != null) {
chatRouter?.exitDetailLocation()
} else {
startActivity(MainActivity.clearTopAndOpenDetail(requireContext(), MainNavigationDetailLocation.Empty))
startActivity(MainActivity.clearTopAndExitDetail(requireContext()))
}
}
@@ -6,8 +6,8 @@
package org.thoughtcrime.securesms.components.settings.conversation
import androidx.fragment.app.FragmentActivity
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.recipients.Recipient
/**
@@ -20,7 +20,7 @@ object ConversationSettingsNavigator {
recipient: Recipient
) {
if (activity is MainNavigationChatDetailRouter) {
activity.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
activity.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))
return
}
@@ -195,7 +195,6 @@ private fun NewConversationScreenUi(
RecipientPickerScaffold(
title = stringResource(R.string.NewConversationActivity__new_message),
forceSplitPane = uiState.forceSplitPaneOnCompactLandscape,
onNavigateUpClick = callbacks::onBackPressed,
topAppBarActions = { TopAppBarActions(callbacks) },
snackbarHostState = snackbarHostState,
@@ -405,9 +404,7 @@ private fun UserMessagesHost(
private fun NewConversationScreenPreview() {
Previews.Preview {
NewConversationScreenUi(
uiState = NewConversationUiState(
forceSplitPaneOnCompactLandscape = false
),
uiState = NewConversationUiState(),
callbacks = NewConversationUiCallbacks.Empty
)
}
@@ -179,7 +179,6 @@ class NewConversationViewModel : ViewModel() {
}
data class NewConversationUiState(
val forceSplitPaneOnCompactLandscape: Boolean = SignalStore.internal.forceSplitPane,
val searchQuery: String = "",
val isLookingUpRecipient: Boolean = false,
val isRefreshingContacts: Boolean = false,
@@ -27,8 +27,8 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.jobs.ConversationShortcutUpdateJob
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import java.util.concurrent.TimeUnit
@@ -147,9 +147,9 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
}
}
override fun goToChatDetail(location: MainNavigationDetailLocation.Chats) {
override fun goToChatDetail(location: MainDetailRoute.Chats) {
when (location) {
is MainNavigationDetailLocation.Chats.ConversationSettings -> {
is MainDetailRoute.Chats.ConversationSettings -> {
lifecycleScope.launch {
val args = ConversationSettingsNavHostFragment.createArgs(location.recipientId)
supportFragmentManager
@@ -161,7 +161,7 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo
}
}
is MainNavigationDetailLocation.Chats.MessageDetails -> {
is MainDetailRoute.Chats.MessageDetails -> {
MessageDetailsFragment.create(location.messageId, location.recipientId)
.show(supportFragmentManager, MESSAGE_DETAILS_FRAGMENT_TAG)
}
@@ -296,9 +296,9 @@ 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.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainNavigationChatDetailRouter
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
@@ -3151,7 +3151,7 @@ class ConversationFragment :
private fun handleDisplayDetails(conversationMessage: ConversationMessage) {
val recipientSnapshot = viewModel.recipientSnapshot ?: return
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
chatRouter.goToChatDetail(MainDetailRoute.Chats.MessageDetails(recipientSnapshot.id, MessageId(conversationMessage.messageRecord.id)))
}
private fun handleDeleteMessages(messageParts: Set<MultiselectPart>) {
@@ -3707,7 +3707,7 @@ class ConversationFragment :
} else if (messageRecord.hasFailedWithNetworkFailures()) {
ConversationDialogs.displayMessageCouldNotBeSentDialog(requireContext(), messageRecord)
} else {
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))
chatRouter.goToChatDetail(MainDetailRoute.Chats.MessageDetails(recipientId, MessageId(messageRecord.id)))
}
}
@@ -3877,7 +3877,7 @@ class ConversationFragment :
when (action) {
"gift_badge" -> checkoutLauncher.launch(InAppPaymentType.ONE_TIME_GIFT)
"username_edit" -> startActivity(EditProfileActivity.getIntentForUsernameEdit(requireContext()))
"calls_tab" -> startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainNavigationListLocation.CALLS))
"calls_tab" -> startActivity(MainActivity.clearTopAndOpenTab(requireContext(), MainListRoute.Calls))
"chat_folder" -> startActivity(AppSettingsActivity.chatFolders(requireContext()))
"remote_backups" -> {
if (SignalStore.backup.areBackupsEnabled) {
@@ -4388,7 +4388,7 @@ class ConversationFragment :
override fun handleManageGroup() {
viewModel.recipientSnapshot?.let { recipient ->
container.hideKeyboard(composeText)
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
chatRouter.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))
}
}
@@ -4426,7 +4426,7 @@ class ConversationFragment :
viewModel.recipientSnapshot?.let { recipient ->
if (!viewModel.hasMessageRequestState || recipient.isBlocked) {
container.hideKeyboard(composeText)
chatRouter.goToChatDetail(MainNavigationDetailLocation.Chats.ConversationSettings(recipient.id))
chatRouter.goToChatDetail(MainDetailRoute.Chats.ConversationSettings(recipient.id))
}
}
}
@@ -33,7 +33,7 @@ import org.signal.core.ui.view.Stub;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.snackbars.SnackbarState;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.main.MainNavigationListLocation;
import org.thoughtcrime.securesms.main.MainListRoute;
import org.thoughtcrime.securesms.main.MainSnackbarHostKey;
import org.thoughtcrime.securesms.util.ConversationUtil;
@@ -77,7 +77,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS);
mainNavigationViewModel.goTo(MainListRoute.Chats);
}
});
}
@@ -143,7 +143,7 @@ import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
import org.thoughtcrime.securesms.keyvalue.AccountValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.main.MainNavigationListLocation;
import org.thoughtcrime.securesms.main.MainListRoute;
import org.thoughtcrime.securesms.main.MainNavigationViewModel;
import org.thoughtcrime.securesms.main.MainSnackbarHostKey;
import org.thoughtcrime.securesms.main.MainToolbarMode;
@@ -465,7 +465,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), chatListBackHandler);
lifecycleDisposable.bindTo(getViewLifecycleOwner());
lifecycleDisposable.add(mainNavigationViewModel.getTabClickEventsObservable().filter(tab -> tab == MainNavigationListLocation.CHATS)
lifecycleDisposable.add(mainNavigationViewModel.getTabClickEventsObservable().filter(tab -> tab == MainListRoute.Chats)
.subscribe(unused -> {
Log.d(TAG, "Scroll to top please");
LinearLayoutManager layoutManager = (LinearLayoutManager) list.getLayoutManager();
@@ -702,7 +702,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
@Override
public void onShowArchiveClick() {
if (viewModel.currentSelectedConversations().isEmpty()) {
mainNavigationViewModel.goTo(MainNavigationListLocation.ARCHIVE);
mainNavigationViewModel.goTo(MainListRoute.Archive);
}
}
@@ -765,7 +765,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
} else if (event instanceof MainToolbarViewModel.Event.Chats.ClearFilter) {
onClearFilterClick();
} else if (event instanceof MainToolbarViewModel.Event.Chats.CloseArchive) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS);
mainNavigationViewModel.goTo(MainListRoute.Chats);
}
})
);
@@ -16,7 +16,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.signal.core.ui.isSplitPane
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainDetailRoute
/**
* When the user searches for a conversation and then enters a message, we should clear
@@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
* On other screen types, since we are in a multi-pane mode, we can subscribe immediately.
*/
fun Fragment.listenToEventBusWhileResumed(
detailLocation: Flow<MainNavigationDetailLocation>
detailLocation: Flow<MainDetailRoute?>
) {
lifecycleScope.launch {
detailLocation
@@ -37,8 +37,8 @@ fun Fragment.listenToEventBusWhileResumed(
.collectLatest {
if (!resources.isSplitPane()) {
when (it) {
is MainNavigationDetailLocation.Conversation -> unsubscribe()
MainNavigationDetailLocation.Empty -> subscribe()
is MainDetailRoute.Conversation -> unsubscribe()
null -> subscribe()
else -> Unit
}
} else {
@@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.dependencies
import org.signal.core.ui.CoreUiDependencies
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.TextSecurePreferences
object CoreUiDependenciesProvider : CoreUiDependencies.Provider {
@@ -22,8 +21,4 @@ object CoreUiDependenciesProvider : CoreUiDependencies.Provider {
override fun provideIsScreenSecurityEnabled(): Boolean {
return TextSecurePreferences.isScreenSecurityEnabled(AppDependencies.application)
}
override fun provideForceSplitPane(): Boolean {
return SignalStore.internal.forceSplitPane
}
}
@@ -163,7 +163,6 @@ private fun AddMembersScreenUi(
RecipientPickerScaffold(
title = title,
forceSplitPane = uiState.forceSplitPane,
onNavigateUpClick = callbacks::onBackPressed,
topAppBarActions = {},
snackbarHostState = remember { SnackbarHostState() },
@@ -318,7 +317,6 @@ private fun AddMembersScreenPreview() {
Previews.Preview {
AddMembersScreenUi(
uiState = AddMembersUiState(
forceSplitPane = false,
selectionLimits = SelectionLimits.NO_LIMITS
),
callbacks = AddMembersUiCallbacks.Empty
@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersUiState.UserMessage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -134,7 +133,6 @@ class AddMembersViewModel(
}
data class AddMembersUiState(
val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane,
val searchQuery: String = "",
val existingMembersMinusSelf: Set<RecipientId> = emptySet(),
val selectionLimits: SelectionLimits,
@@ -129,7 +129,6 @@ private fun AddToGroupsScreenUi(
RecipientPickerScaffold(
title = title,
forceSplitPane = uiState.forceSplitPane,
onNavigateUpClick = callbacks::onBackPressed,
topAppBarActions = {},
snackbarHostState = remember { SnackbarHostState() },
@@ -282,7 +281,6 @@ private fun AddToSingleGroupScreenPreview() {
Previews.Preview {
AddToGroupsScreenUi(
uiState = AddToGroupsUiState(
forceSplitPane = false,
selectionLimits = null
),
callbacks = AddToGroupsUiCallbacks.Empty
@@ -296,7 +294,6 @@ private fun AddToMultipleGroupsScreenPreview() {
Previews.Preview {
AddToGroupsScreenUi(
uiState = AddToGroupsUiState(
forceSplitPane = false,
selectionLimits = SelectionLimits.NO_LIMITS
),
callbacks = AddToGroupsUiCallbacks.Empty
@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsUiState.UserMessage
import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult
import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -127,7 +126,6 @@ class AddToGroupsViewModel(
}
data class AddToGroupsUiState(
val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane,
val searchQuery: String = "",
val existingGroupMemberships: Set<RecipientId> = emptySet(),
val selectionLimits: SelectionLimits? = null,
@@ -162,7 +162,6 @@ private fun CreateGroupScreenUi(
RecipientPickerScaffold(
title = title,
forceSplitPane = uiState.forceSplitPane,
onNavigateUpClick = callbacks::onBackPressed,
topAppBarActions = {},
snackbarHostState = remember { SnackbarHostState() },
@@ -285,7 +284,6 @@ private fun CreateGroupScreenPreview() {
Previews.Preview {
CreateGroupScreenUi(
uiState = CreateGroupUiState(
forceSplitPane = false,
selectionLimits = SelectionLimits.NO_LIMITS
),
callbacks = CreateGroupUiCallbacks.Empty
@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.contacts.SelectedContact
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.NavTarget
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
@@ -120,7 +119,6 @@ class CreateGroupViewModel : ViewModel() {
}
data class CreateGroupUiState(
val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane,
val searchQuery: String = "",
val selectionLimits: SelectionLimits = RemoteConfig.groupLimits.excludingSelf(),
val newSelections: List<SelectedContact> = emptyList(),
@@ -38,8 +38,6 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val LAST_SCROLL_POSITION: String = "internal.last_scroll_position"
const val CONVERSATION_ITEM_V2_MEDIA: String = "internal.conversation_item_v2_media"
const val WEB_SOCKET_SHADOWING_STATS: String = "internal.web_socket_shadowing_stats"
const val FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE: String = "internal.force.split.pane.on.compact.landscape.ui"
const val FORCE_SINGLE_PANE_ON_ALL_DEVICES: String = "internal.force_single_pane_on_all_devices"
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
const val IMPORTED_BACKUP_DEBUG_INFO: String = "internal.imported_backup_debug_info"
@@ -52,16 +50,6 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
public override fun getKeysToIncludeInBackup(): List<String> = emptyList()
/**
* Force split-pane mode on compact landscape
*/
var forceSplitPane by booleanValue(FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE, false).falseForExternalUsers()
/**
* Force single-pane on all devices
*/
var forceSinglePane by booleanValue(FORCE_SINGLE_PANE_ON_ALL_DEVICES, false).falseForExternalUsers()
/**
* Members will not be added directly to a GV2 even if they could be.
*/
@@ -15,23 +15,21 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.BreakpointPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.showSnackbar
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.components.snackbars.SnackbarHostKey
import org.thoughtcrime.securesms.components.snackbars.rememberSnackbarState
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.window.NavigationType
interface MainBottomChromeCallback : MainFloatingActionButtonsCallback {
fun onMegaphoneVisible(megaphone: Megaphone)
@@ -40,14 +38,14 @@ interface MainBottomChromeCallback : MainFloatingActionButtonsCallback {
object Empty : MainBottomChromeCallback {
override fun onNewChatClick() = Unit
override fun onNewCallClick() = Unit
override fun onCameraClick(destination: MainNavigationListLocation) = Unit
override fun onCameraClick(destination: MainListRoute) = Unit
override fun onMegaphoneVisible(megaphone: Megaphone) = Unit
override fun onSnackbarDismissed() = Unit
}
}
data class MainBottomChromeState(
val destination: MainNavigationListLocation = MainNavigationListLocation.CHATS,
val destination: MainListRoute = MainListRoute.Chats,
val megaphoneState: MainMegaphoneState = MainMegaphoneState(),
val mainToolbarMode: MainToolbarMode = MainToolbarMode.FULL
)
@@ -1,118 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.rememberIsSplitPane
private val MEDIUM_CONTENT_CORNERS = 18.dp
private val EXTENDED_CONTENT_CORNERS = 14.dp
/**
* Describes metrics for the content layout (list and detail) of the main screen.
*
* @param shape The clipping shape of each of the list and detail fragments
* @param navigationBarShape The clipping shape applied to the navigation bar, if present.
* @param partitionWidth The width of the divider between list and detail
* @param listPaddingStart The padding between the list pane and the navigation rail
* @param detailPaddingEnd The padding at the end of the detail pane
*/
@Immutable
data class MainContentLayoutData(
val shape: Shape,
val navigationBarShape: Shape,
val partitionWidth: Dp,
val listPaddingStart: Dp,
val detailPaddingEnd: Dp
) {
private val extraPadding: Dp = partitionWidth + listPaddingStart + detailPaddingEnd
/**
* Whether or not the WindowSizeClass supports drag handles.
*/
@Composable
fun hasDragHandle(): Boolean {
return LocalResources.current.rememberIsSplitPane()
}
/**
* Calculates the default preferred width
*/
@Composable
fun rememberDefaultPanePreferredWidth(maxWidth: Dp): Dp {
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
return remember(maxWidth, windowSizeClass, isSplitPane) {
when {
!isSplitPane -> maxWidth
windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> 416.dp
else -> (maxWidth - extraPadding) / 2f
}
}
}
companion object {
/**
* Uses the [WindowSizeClass] and [MainToolbarMode] to build out a MainContentLayoutData.
*/
@Composable
fun rememberContentLayoutData(mode: MainToolbarMode): MainContentLayoutData {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val resources = LocalResources.current
val breakpoint = resources.getWindowBreakpoint()
val isSplitPane = resources.rememberIsSplitPane()
return remember(windowSizeClass, mode, breakpoint, isSplitPane) {
val isLargeWindowSize = breakpoint is WindowBreakpoint.Large
MainContentLayoutData(
shape = when {
!isSplitPane -> RectangleShape
isLargeWindowSize -> RoundedCornerShape(EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(MEDIUM_CONTENT_CORNERS)
},
navigationBarShape = when {
!isSplitPane -> RectangleShape
isLargeWindowSize -> RoundedCornerShape(0.dp, 0.dp, EXTENDED_CONTENT_CORNERS, EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(0.dp, 0.dp, MEDIUM_CONTENT_CORNERS, MEDIUM_CONTENT_CORNERS)
},
partitionWidth = when {
!isSplitPane -> 0.dp
isLargeWindowSize -> 24.dp
else -> 13.dp
},
listPaddingStart = when {
!isSplitPane -> 0.dp
else -> {
when (mode) {
MainToolbarMode.SEARCH -> 24.dp
else -> 0.dp
}
}
},
detailPaddingEnd = when {
!isSplitPane -> 0.dp
isLargeWindowSize -> 24.dp
else -> 12.dp
}
)
}
}
}
}
@@ -5,32 +5,44 @@
package org.thoughtcrime.securesms.main
import androidx.compose.runtime.snapshots.SnapshotStateList
import org.signal.core.ui.compose.split.ListDetailBackStack
import org.signal.core.ui.compose.split.detailLocation
import org.signal.core.ui.compose.split.listLocation
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.recipients.RecipientId
interface MainDetailBackStack {
val entries: SnapshotStateList<MainNavigationDetailLocation>
/**
* The list currently being displayed.
*/
val ListDetailBackStack.listLocation: MainListRoute
get() = listLocation<MainListRoute>()
val isEmpty: Boolean
get() = entries.singleOrNull() is MainNavigationDetailLocation.Empty
/**
* The detail content displayed above the current list, or null when the list is showing on its own.
*/
val ListDetailBackStack.detailLocation: MainDetailRoute?
get() = detailLocation<MainDetailRoute>()
fun push(location: MainNavigationDetailLocation)
/**
* Pops the top entry off the stack. Returns true if something was popped, false if the stack is already at its root.
*/
fun pop(): Boolean {
if (entries.size <= 1) return false
entries.removeAt(entries.lastIndex)
return true
/**
* The recipient whose content is displayed by the topmost entry that has one.
*/
val ListDetailBackStack.activeRecipientId: RecipientId?
get() = asReversed().firstNotNullOfOrNull {
when (it) {
is MainDetailRoute.Conversation -> it.conversationArgs.recipientId
is MainDetailRoute.Chats -> it.controllerKey
else -> null
}
}
/**
* Resets the stack to its base empty state.
*/
fun reset() {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
if (entries.isEmpty()) {
entries.add(MainNavigationDetailLocation.Empty)
/**
* The call whose content is displayed by the topmost entry that has one.
*/
val ListDetailBackStack.activeCallId: CallLogRow.Id?
get() = asReversed().firstNotNullOfOrNull {
when (it) {
is MainDetailRoute.Calls -> it.controllerKey
is MainDetailRoute.CallLinkDetails -> it.controllerKey
else -> null
}
}
}
@@ -6,11 +6,11 @@
package org.thoughtcrime.securesms.main
import android.os.Parcelable
import androidx.navigation3.runtime.NavKey
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.signal.core.ui.compose.split.DetailNavKey
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.database.model.MessageId
@@ -22,25 +22,18 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
*/
@Serializable
@Parcelize
sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
sealed interface MainDetailRoute : Parcelable, DetailNavKey {
/**
* Flag utilized internally to determine whether the given route is displayed at the root
* of a task stack (or on top of Empty)
* Whether this is the bottom-most piece of detail content rather than something stacked on top of it.
* Pushing one replaces the detail already displayed above the current list; see [push].
*/
@IgnoredOnParcel
val isContentRoot: Boolean
override val isContentRoot: Boolean
get() = false
@Serializable
data object Empty : MainNavigationDetailLocation {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
}
@Serializable
data class Conversation(val conversationArgs: ConversationArgs) : MainNavigationDetailLocation {
data class Conversation(val conversationArgs: ConversationArgs) : MainDetailRoute {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@@ -51,7 +44,7 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
}
@Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : MainNavigationDetailLocation {
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : MainDetailRoute {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@@ -65,7 +58,7 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
* Subscreens that can be displayed within the chats tab.
*/
@Parcelize
sealed interface Chats : MainNavigationDetailLocation {
sealed interface Chats : MainDetailRoute {
val controllerKey: RecipientId
@@ -94,7 +87,7 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
* Subscreens that can be displayed within the calls tab.
*/
@Parcelize
sealed interface Calls : MainNavigationDetailLocation {
sealed interface Calls : MainDetailRoute {
val controllerKey: CallLogRow.Id
@Parcelize
@@ -115,7 +108,7 @@ sealed interface MainNavigationDetailLocation : Parcelable, NavKey {
* Subscreens that can be displayed within the stories tab.
*/
@Parcelize
sealed class Stories : MainNavigationDetailLocation {
sealed class Stories : MainDetailRoute {
@Transient
@IgnoredOnParcel
override val isContentRoot: Boolean = true
@@ -36,12 +36,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.window.NavigationType
import kotlin.math.roundToInt
import org.signal.core.ui.R as CoreUiR
@@ -51,18 +51,18 @@ private val ACTION_BUTTON_SPACING = 16.dp
interface MainFloatingActionButtonsCallback {
fun onNewChatClick()
fun onNewCallClick()
fun onCameraClick(destination: MainNavigationListLocation)
fun onCameraClick(destination: MainListRoute)
object Empty : MainFloatingActionButtonsCallback {
override fun onNewChatClick() = Unit
override fun onNewCallClick() = Unit
override fun onCameraClick(destination: MainNavigationListLocation) = Unit
override fun onCameraClick(destination: MainListRoute) = Unit
}
}
@Composable
fun MainFloatingActionButtons(
destination: MainNavigationListLocation,
destination: MainListRoute,
callback: MainFloatingActionButtonsCallback,
modifier: Modifier = Modifier,
navigationType: NavigationType = NavigationType.rememberNavigationType()
@@ -114,10 +114,10 @@ fun MainFloatingActionButtons(
@Composable
private fun BoxScope.SecondaryActionButton(
destination: MainNavigationListLocation,
destination: MainListRoute,
boxHeightPx: Int,
elevation: Dp,
onCameraClick: (MainNavigationListLocation) -> Unit
onCameraClick: (MainListRoute) -> Unit
) {
val navigationType = NavigationType.rememberNavigationType()
val secondaryButtonAlignment = remember(navigationType) {
@@ -139,7 +139,7 @@ private fun BoxScope.SecondaryActionButton(
}
AnimatedVisibility(
visible = destination == MainNavigationListLocation.CHATS || destination == MainNavigationListLocation.ARCHIVE,
visible = destination == MainListRoute.Chats || destination == MainListRoute.Archive,
modifier = Modifier.align(secondaryButtonAlignment),
enter = slideInVertically(initialOffsetY = offsetYProvider),
exit = slideOutVertically(targetOffsetY = offsetYProvider)
@@ -155,7 +155,7 @@ private fun BoxScope.SecondaryActionButton(
contentColor = MaterialTheme.colorScheme.onSurface
),
onClick = {
onCameraClick(MainNavigationListLocation.CHATS)
onCameraClick(MainListRoute.Chats)
},
shadowElevation = animatedElevation
)
@@ -164,18 +164,18 @@ private fun BoxScope.SecondaryActionButton(
@Composable
private fun PrimaryActionButton(
destination: MainNavigationListLocation,
destination: MainListRoute,
elevation: Dp,
onNewChatClick: () -> Unit = {},
onCameraClick: (MainNavigationListLocation) -> Unit = {},
onCameraClick: (MainListRoute) -> Unit = {},
onNewCallClick: () -> Unit = {}
) {
val onClick = remember(destination) {
when (destination) {
MainNavigationListLocation.ARCHIVE -> onNewChatClick
MainNavigationListLocation.CHATS -> onNewChatClick
MainNavigationListLocation.CALLS -> onNewCallClick
MainNavigationListLocation.STORIES -> {
MainListRoute.Archive -> onNewChatClick
MainListRoute.Chats -> onNewChatClick
MainListRoute.Calls -> onNewCallClick
MainListRoute.Stories -> {
{ onCameraClick(destination) }
}
}
@@ -187,10 +187,10 @@ private fun PrimaryActionButton(
icon = {
AnimatedContent(destination) { targetState ->
val (icon, contentDescriptionId) = when (targetState) {
MainNavigationListLocation.ARCHIVE -> CoreUiR.drawable.symbol_edit_24 to R.string.conversation_list_fragment__fab_content_description
MainNavigationListLocation.CHATS -> CoreUiR.drawable.symbol_edit_24 to R.string.conversation_list_fragment__fab_content_description
MainNavigationListLocation.CALLS -> R.drawable.symbol_phone_plus_24 to R.string.CallLogFragment__start_a_new_call
MainNavigationListLocation.STORIES -> CoreUiR.drawable.symbol_camera_24 to R.string.conversation_list_fragment__open_camera_description
MainListRoute.Archive -> CoreUiR.drawable.symbol_edit_24 to R.string.conversation_list_fragment__fab_content_description
MainListRoute.Chats -> CoreUiR.drawable.symbol_edit_24 to R.string.conversation_list_fragment__fab_content_description
MainListRoute.Calls -> R.drawable.symbol_phone_plus_24 to R.string.CallLogFragment__start_a_new_call
MainListRoute.Stories -> CoreUiR.drawable.symbol_camera_24 to R.string.conversation_list_fragment__open_camera_description
}
Icon(
@@ -247,19 +247,19 @@ private fun MainFloatingActionButton(
@DayNightPreviews
@Composable
private fun MainFloatingActionButtonsNavigationRailPreview() {
var currentDestination by remember { mutableStateOf(MainNavigationListLocation.CHATS) }
var currentDestination by remember { mutableStateOf(MainListRoute.Chats) }
val callback = remember {
object : MainFloatingActionButtonsCallback {
override fun onCameraClick(destination: MainNavigationListLocation) {
currentDestination = MainNavigationListLocation.CALLS
override fun onCameraClick(destination: MainListRoute) {
currentDestination = MainListRoute.Calls
}
override fun onNewChatClick() {
currentDestination = MainNavigationListLocation.STORIES
currentDestination = MainListRoute.Stories
}
override fun onNewCallClick() {
currentDestination = MainNavigationListLocation.CHATS
currentDestination = MainListRoute.Chats
}
}
}
@@ -276,19 +276,19 @@ private fun MainFloatingActionButtonsNavigationRailPreview() {
@DayNightPreviews
@Composable
private fun MainFloatingActionButtonsNavigationBarPreview() {
var currentDestination by remember { mutableStateOf(MainNavigationListLocation.CHATS) }
var currentDestination by remember { mutableStateOf(MainListRoute.Chats) }
val callback = remember {
object : MainFloatingActionButtonsCallback {
override fun onCameraClick(destination: MainNavigationListLocation) {
currentDestination = MainNavigationListLocation.CALLS
override fun onCameraClick(destination: MainListRoute) {
currentDestination = MainListRoute.Calls
}
override fun onNewChatClick() {
currentDestination = MainNavigationListLocation.STORIES
currentDestination = MainListRoute.Stories
}
override fun onNewCallClick() {
currentDestination = MainNavigationListLocation.CHATS
currentDestination = MainListRoute.Chats
}
}
}
@@ -0,0 +1,50 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.annotation.RawRes
import androidx.annotation.StringRes
import kotlinx.serialization.Serializable
import org.signal.core.ui.compose.split.ListNavKey
import org.thoughtcrime.securesms.R
/**
* Main activity tabs (chat, stories, calls) that the user can select between. This is basically tied to something we can show in the 'list'
* panel of the main activity.
*/
@Serializable
enum class MainListRoute(
@get:StringRes val label: Int,
@get:RawRes val icon: Int,
@get:StringRes val contentDescription: Int = label
) : ListNavKey {
Chats(
label = R.string.ConversationListTabs__chats,
icon = R.raw.chats_28
),
Archive(
label = R.string.ConversationListTabs__chats,
icon = R.raw.chats_28
),
Calls(
label = R.string.ConversationListTabs__calls,
icon = R.raw.calls_28
),
Stories(
label = R.string.ConversationListTabs__stories,
icon = R.raw.stories_28
);
val isChatsTab: Boolean
get() = this == Chats || this == Archive
/**
* The tab this list location is displayed under. The archive is a list pushed onto the chats stack
* rather than a tab of its own.
*/
val tab: MainListRoute
get() = if (isChatsTab) Chats else this
}
@@ -1,389 +1,77 @@
/*
* Copyright 2025 Signal Messenger, LLC
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.annotation.RawRes
import androidx.annotation.StringRes
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieComposition
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
private val LOTTIE_SIZE = 28.dp
enum class MainNavigationListLocation(
@StringRes val label: Int,
@RawRes val icon: Int,
@StringRes val contentDescription: Int = label
) {
CHATS(
label = R.string.ConversationListTabs__chats,
icon = R.raw.chats_28
),
ARCHIVE(
label = R.string.ConversationListTabs__chats,
icon = R.raw.chats_28
),
CALLS(
label = R.string.ConversationListTabs__calls,
icon = R.raw.calls_28
),
STORIES(
label = R.string.ConversationListTabs__stories,
icon = R.raw.stories_28
);
val isChatsTab: Boolean
get() = this == CHATS || this == ARCHIVE
}
data class MainNavigationState(
val chatsCount: Int = 0,
val callsCount: Int = 0,
val storiesCount: Int = 0,
val storyFailure: Boolean = false,
val isStoriesFeatureEnabled: Boolean = true,
val currentListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS,
val compact: Boolean = false
)
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import org.signal.core.ui.compose.split.listEntry
import org.signal.core.ui.compose.split.rememberCurrentDecoratedNavEntries
import org.thoughtcrime.securesms.calls.CallsListPane
import org.thoughtcrime.securesms.calls.registerCallsTabDetailRoutes
import org.thoughtcrime.securesms.chats.ArchiveListPane
import org.thoughtcrime.securesms.chats.ChatsListPane
import org.thoughtcrime.securesms.chats.ConversationTransitionState
import org.thoughtcrime.securesms.chats.registerChatsTabDetailRoutes
import org.thoughtcrime.securesms.stories.StoriesListPane
import org.thoughtcrime.securesms.stories.registerStoriesTabDetailRoutes
/**
* Chats list bottom navigation bar.
* Builds our nav entries and decorates them so they can save state, handing back the entries of whichever
* tab is displayed.
*
* A new *backstack* on [MainNavigationViewModel] is picked up here for free; a new *screen* needs a route
* registering in [rememberMainNavEntryProvider].
*/
@Composable
fun MainNavigationBar(
state: MainNavigationState,
onDestinationSelected: (MainNavigationListLocation) -> Unit
) {
NavigationBar(
containerColor = SignalTheme.colors.colorSurface2,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.height(if (state.compact) 48.dp else 80.dp),
windowInsets = WindowInsets(0, 0, 0, 0)
) {
val entries = remember(state.isStoriesFeatureEnabled) {
if (state.isStoriesFeatureEnabled) {
MainNavigationListLocation.entries.filterNot { it == MainNavigationListLocation.ARCHIVE }
} else {
MainNavigationListLocation.entries.filterNot { it == MainNavigationListLocation.STORIES || it == MainNavigationListLocation.ARCHIVE }
}
}
entries.forEach { destination ->
val badgeCount = when (destination) {
MainNavigationListLocation.ARCHIVE -> error("Not supported")
MainNavigationListLocation.CHATS -> state.chatsCount
MainNavigationListLocation.CALLS -> state.callsCount
MainNavigationListLocation.STORIES -> state.storiesCount
}
val selected = state.currentListLocation == destination
NavigationBarItem(
selected = selected,
icon = {
NavigationDestinationIcon(
destination = destination,
selected = selected
)
},
label = if (state.compact) null else {
{ NavigationDestinationLabel(destination) }
},
onClick = {
onDestinationSelected(destination)
},
modifier = Modifier.drawNavigationBarBadge(count = badgeCount, compact = state.compact)
)
}
}
}
/**
* Draws badge over navigation bar item. We do this since they're required to be inside a row,
* and things get really funky or clip weird if we try to use a normal composable.
*/
@Composable
private fun Modifier.drawNavigationBarBadge(count: Int, compact: Boolean): Modifier {
return if (count <= 0) {
this
} else {
val formatted = formatCount(count)
val textMeasurer = rememberTextMeasurer()
val color = colorResource(R.color.ConversationListTabs__unread)
val textStyle = MaterialTheme.typography.labelMedium
val textLayoutResult = remember(formatted) {
textMeasurer.measure(formatted, textStyle)
}
var size by remember { mutableStateOf(IntSize.Zero) }
val padding = with(LocalDensity.current) {
4.dp.toPx()
}
val xOffsetExtra = with(LocalDensity.current) {
4.dp.toPx()
}
val yOffset = with(LocalDensity.current) {
if (compact) 6.dp.toPx() else 10.dp.toPx()
}
this
.onSizeChanged {
size = it
}
.drawWithContent {
drawContent()
val xOffset = size.width.toFloat() / 2f + xOffsetExtra
val yRadius = size.height.toFloat() / 2f
if (size != IntSize.Zero) {
drawRoundRect(
color = color,
topLeft = Offset(xOffset, yOffset),
size = Size(textLayoutResult.size.width.toFloat() + padding * 2, textLayoutResult.size.height.toFloat()),
cornerRadius = CornerRadius(yRadius, yRadius)
)
drawText(
textLayoutResult = textLayoutResult,
color = Color.White,
topLeft = Offset(xOffset + padding, yOffset)
)
}
}
}
}
/**
* Navigation Rail for medium and large form factor devices.
*/
@Composable
fun MainNavigationRail(
state: MainNavigationState,
mainFloatingActionButtonsCallback: MainFloatingActionButtonsCallback,
onDestinationSelected: (MainNavigationListLocation) -> Unit
) {
NavigationRail(
containerColor = SignalTheme.colors.colorSurface1
) {
Spacer(modifier = Modifier.height(40.dp).weight(1f, fill = false))
MainFloatingActionButtons(
destination = state.currentListLocation,
callback = mainFloatingActionButtonsCallback,
modifier = Modifier.padding(vertical = 8.dp)
)
Spacer(modifier = Modifier.height(40.dp).weight(1f, fill = false))
val entries = remember(state.isStoriesFeatureEnabled) {
if (state.isStoriesFeatureEnabled) {
MainNavigationListLocation.entries.filterNot { it == MainNavigationListLocation.ARCHIVE }
} else {
MainNavigationListLocation.entries.filterNot { it == MainNavigationListLocation.STORIES || it == MainNavigationListLocation.ARCHIVE }
}
}
val selectedDestination = if (state.currentListLocation == MainNavigationListLocation.ARCHIVE) {
MainNavigationListLocation.CHATS
} else {
state.currentListLocation
}
entries.forEachIndexed { idx, destination ->
val selected = selectedDestination == destination
Box {
NavigationRailItem(
modifier = Modifier.padding(bottom = if (MainNavigationListLocation.entries.lastIndex == idx) 0.dp else 16.dp),
icon = {
NavigationDestinationIcon(
destination = destination,
selected = selected
)
},
label = {
NavigationDestinationLabel(destination)
},
selected = selected,
onClick = {
onDestinationSelected(destination)
}
)
NavigationRailCountIndicator(
state = state,
destination = destination
)
}
}
}
}
@Composable
private fun BoxScope.NavigationRailCountIndicator(
state: MainNavigationState,
destination: MainNavigationListLocation
) {
val count = remember(state, destination) {
when (destination) {
MainNavigationListLocation.ARCHIVE -> error("Not supported")
MainNavigationListLocation.CHATS -> state.chatsCount
MainNavigationListLocation.CALLS -> state.callsCount
MainNavigationListLocation.STORIES -> state.storiesCount
}
}
if (count > 0) {
Box(
modifier = Modifier
.padding(start = 42.dp)
.height(16.dp)
.defaultMinSize(minWidth = 16.dp)
.background(color = colorResource(R.color.ConversationListTabs__unread), shape = RoundedCornerShape(percent = 50))
.align(Alignment.TopStart)
) {
Text(
text = formatCount(count),
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 4.dp)
)
}
}
}
@Composable
private fun NavigationDestinationIcon(
destination: MainNavigationListLocation,
selected: Boolean
) {
val dynamicProperties = rememberLottieDynamicProperties(
rememberLottieDynamicProperty(
property = LottieProperty.COLOR_FILTER,
value = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
MaterialTheme.colorScheme.onSurface.hashCode(),
BlendModeCompat.SRC_ATOP
),
keyPath = arrayOf("**")
)
)
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(destination.icon))
val progress by animateFloatAsState(targetValue = if (selected) 1f else 0f, animationSpec = tween(durationMillis = composition?.duration?.toInt() ?: 0))
LottieAnimation(
composition = composition,
progress = { if (selected) progress else 0f },
dynamicProperties = dynamicProperties,
modifier = Modifier.size(LOTTIE_SIZE)
fun rememberDecoratedDetailEntries(
mainNavigationViewModel: MainNavigationViewModel,
convoTransitionState: ConversationTransitionState,
isSplitPane: Boolean
): List<NavEntry<NavKey>> {
return rememberCurrentDecoratedNavEntries(
navigator = mainNavigationViewModel.navigator,
entryProvider = rememberMainNavEntryProvider(convoTransitionState, isSplitPane)
)
}
/**
* The entry-provider for main screen. The methods which are called are where you'd add additional
* screens for different tabs.
*/
@Composable
private fun NavigationDestinationLabel(destination: MainNavigationListLocation) {
Text(stringResource(destination.label))
}
@Composable
private fun formatCount(count: Int): String {
if (count > 99) {
return stringResource(R.string.ConversationListTabs__99p)
}
return count.toString()
}
@DayNightPreviews
@Preview(device = "spec:parent=pixel_7,orientation=landscape")
@Composable
private fun MainNavigationRailPreview() {
Previews.Preview {
var selected by remember { mutableStateOf(MainNavigationListLocation.CHATS) }
MainNavigationRail(
state = MainNavigationState(
chatsCount = 500,
callsCount = 10,
storiesCount = 5,
currentListLocation = selected
),
mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty,
onDestinationSelected = { selected = it }
)
private fun rememberMainNavEntryProvider(
convoTransitionState: ConversationTransitionState,
isSplitPane: Boolean
): (NavKey) -> NavEntry<NavKey> {
return remember(convoTransitionState, isSplitPane) {
entryProvider {
registerMainScreenRoutes()
registerChatsTabDetailRoutes(convoTransitionState)
registerCallsTabDetailRoutes(isSplitPane)
registerStoriesTabDetailRoutes()
}
}
}
@DayNightPreviews
@Composable
private fun MainNavigationBarPreview() {
Previews.Preview {
var selected by remember { mutableStateOf(MainNavigationListLocation.CHATS) }
MainNavigationBar(
state = MainNavigationState(
chatsCount = 500,
callsCount = 10,
storiesCount = 5,
currentListLocation = selected,
compact = false
),
onDestinationSelected = { selected = it }
)
/**
* Registers the main list of tab routes (list locations)
*/
private fun EntryProviderScope<NavKey>.registerMainScreenRoutes() {
listEntry<MainListRoute> { location ->
when (location) {
MainListRoute.Chats -> ChatsListPane(modifier = Modifier.fillMaxSize())
MainListRoute.Archive -> ArchiveListPane(modifier = Modifier.fillMaxSize())
MainListRoute.Calls -> CallsListPane(modifier = Modifier.fillMaxSize())
MainListRoute.Stories -> StoriesListPane(modifier = Modifier.fillMaxSize())
}
}
}
@@ -0,0 +1,334 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.core.graphics.BlendModeColorFilterCompat
import androidx.core.graphics.BlendModeCompat
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.rememberLottieComposition
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
private val LOTTIE_SIZE = 28.dp
/**
* Chats list bottom navigation bar.
*/
@Composable
fun MainNavigationBar(
state: MainNavigationBarState,
onDestinationSelected: (MainListRoute) -> Unit
) {
NavigationBar(
containerColor = SignalTheme.colors.colorSurface2,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.height(if (state.compact) 48.dp else 80.dp),
windowInsets = WindowInsets(0, 0, 0, 0)
) {
state.destinations.forEach { destination ->
val badgeCount = when (destination) {
MainListRoute.Archive -> error("Not supported")
MainListRoute.Chats -> state.chatsCount
MainListRoute.Calls -> state.callsCount
MainListRoute.Stories -> state.storiesCount
}
val selected = state.currentListLocation == destination
NavigationBarItem(
selected = selected,
icon = {
NavigationDestinationIcon(
destination = destination,
selected = selected
)
},
label = if (state.compact) null else {
{ NavigationDestinationLabel(destination) }
},
onClick = {
onDestinationSelected(destination)
},
modifier = Modifier.drawNavigationBarBadge(count = badgeCount, compact = state.compact)
)
}
}
}
/**
* Draws badge over navigation bar item. We do this since they're required to be inside a row,
* and things get really funky or clip weird if we try to use a normal composable.
*/
@Composable
private fun Modifier.drawNavigationBarBadge(count: Int, compact: Boolean): Modifier {
return if (count <= 0) {
this
} else {
val formatted = formatCount(count)
val textMeasurer = rememberTextMeasurer()
val color = colorResource(R.color.ConversationListTabs__unread)
val textStyle = MaterialTheme.typography.labelMedium
val textLayoutResult = remember(formatted) {
textMeasurer.measure(formatted, textStyle)
}
var size by remember { mutableStateOf(IntSize.Zero) }
val padding = with(LocalDensity.current) {
4.dp.toPx()
}
val xOffsetExtra = with(LocalDensity.current) {
4.dp.toPx()
}
val yOffset = with(LocalDensity.current) {
if (compact) 6.dp.toPx() else 10.dp.toPx()
}
this
.onSizeChanged {
size = it
}
.drawWithContent {
drawContent()
val xOffset = size.width.toFloat() / 2f + xOffsetExtra
val yRadius = size.height.toFloat() / 2f
if (size != IntSize.Zero) {
drawRoundRect(
color = color,
topLeft = Offset(xOffset, yOffset),
size = Size(textLayoutResult.size.width.toFloat() + padding * 2, textLayoutResult.size.height.toFloat()),
cornerRadius = CornerRadius(yRadius, yRadius)
)
drawText(
textLayoutResult = textLayoutResult,
color = Color.White,
topLeft = Offset(xOffset + padding, yOffset)
)
}
}
}
}
/**
* Navigation Rail for medium and large form factor devices.
*/
@Composable
fun MainNavigationRail(
state: MainNavigationBarState,
mainFloatingActionButtonsCallback: MainFloatingActionButtonsCallback,
onDestinationSelected: (MainListRoute) -> Unit
) {
NavigationRail(
containerColor = SignalTheme.colors.colorSurface1
) {
Spacer(modifier = Modifier.height(40.dp).weight(1f, fill = false))
MainFloatingActionButtons(
destination = state.currentListLocation,
callback = mainFloatingActionButtonsCallback,
modifier = Modifier.padding(vertical = 8.dp)
)
Spacer(modifier = Modifier.height(40.dp).weight(1f, fill = false))
val selectedDestination = if (state.currentListLocation == MainListRoute.Archive) {
MainListRoute.Chats
} else {
state.currentListLocation
}
state.destinations.forEachIndexed { idx, destination ->
val selected = selectedDestination == destination
Box {
NavigationRailItem(
modifier = Modifier.padding(bottom = if (state.destinations.lastIndex == idx) 0.dp else 16.dp),
icon = {
NavigationDestinationIcon(
destination = destination,
selected = selected
)
},
label = {
NavigationDestinationLabel(destination)
},
selected = selected,
onClick = {
onDestinationSelected(destination)
}
)
NavigationRailCountIndicator(
state = state,
destination = destination
)
}
}
}
}
@Composable
private fun BoxScope.NavigationRailCountIndicator(
state: MainNavigationBarState,
destination: MainListRoute
) {
val count = remember(state, destination) {
when (destination) {
MainListRoute.Archive -> error("Not supported")
MainListRoute.Chats -> state.chatsCount
MainListRoute.Calls -> state.callsCount
MainListRoute.Stories -> state.storiesCount
}
}
if (count > 0) {
Box(
modifier = Modifier
.padding(start = 42.dp)
.height(16.dp)
.defaultMinSize(minWidth = 16.dp)
.background(color = colorResource(R.color.ConversationListTabs__unread), shape = RoundedCornerShape(percent = 50))
.align(Alignment.TopStart)
) {
Text(
text = formatCount(count),
style = MaterialTheme.typography.labelMedium,
color = Color.White,
modifier = Modifier
.align(Alignment.Center)
.padding(horizontal = 4.dp)
)
}
}
}
@Composable
private fun NavigationDestinationIcon(
destination: MainListRoute,
selected: Boolean
) {
val dynamicProperties = rememberLottieDynamicProperties(
rememberLottieDynamicProperty(
property = LottieProperty.COLOR_FILTER,
value = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(
MaterialTheme.colorScheme.onSurface.hashCode(),
BlendModeCompat.SRC_ATOP
),
keyPath = arrayOf("**")
)
)
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(destination.icon))
val progress by animateFloatAsState(targetValue = if (selected) 1f else 0f, animationSpec = tween(durationMillis = composition?.duration?.toInt() ?: 0))
LottieAnimation(
composition = composition,
progress = { if (selected) progress else 0f },
dynamicProperties = dynamicProperties,
modifier = Modifier.size(LOTTIE_SIZE)
)
}
@Composable
private fun NavigationDestinationLabel(destination: MainListRoute) {
Text(stringResource(destination.label))
}
@Composable
private fun formatCount(count: Int): String {
if (count > 99) {
return stringResource(R.string.ConversationListTabs__99p)
}
return count.toString()
}
@DayNightPreviews
@Preview(device = "spec:parent=pixel_7,orientation=landscape")
@Composable
private fun MainNavigationRailPreview() {
Previews.Preview {
var selected by remember { mutableStateOf(MainListRoute.Chats) }
MainNavigationRail(
state = MainNavigationBarState(
chatsCount = 500,
callsCount = 10,
storiesCount = 5,
currentListLocation = selected
),
mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty,
onDestinationSelected = { selected = it }
)
}
}
@DayNightPreviews
@Composable
private fun MainNavigationBarPreview() {
Previews.Preview {
var selected by remember { mutableStateOf(MainListRoute.Chats) }
MainNavigationBar(
state = MainNavigationBarState(
chatsCount = 500,
callsCount = 10,
storiesCount = 5,
currentListLocation = selected,
compact = false
),
onDestinationSelected = { selected = it }
)
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
/**
* State of the main navigation bar or rail.
*
* @param destinations the tabs to display, in order. Whoever builds this state decides which ones the user
* gets; the bar and the rail display exactly what they are handed.
*/
data class MainNavigationBarState(
val chatsCount: Int = 0,
val callsCount: Int = 0,
val storiesCount: Int = 0,
val storyFailure: Boolean = false,
val destinations: List<MainListRoute> = ALL_DESTINATIONS,
val currentListLocation: MainListRoute = MainListRoute.Chats,
val compact: Boolean = false
) {
companion object {
/**
* Every tab there is, in display order. The archive is not among them: it is a list within chats
* rather than a tab of its own.
*/
val ALL_DESTINATIONS = listOf(MainListRoute.Chats, MainListRoute.Calls, MainListRoute.Stories)
}
}
@@ -10,7 +10,7 @@ package org.thoughtcrime.securesms.main
*/
interface MainNavigationChatDetailRouter {
fun exitDetailLocation()
fun goToChatDetail(location: MainNavigationDetailLocation.Chats)
fun goToChatDetail(location: MainDetailRoute.Chats)
}
/**
@@ -18,17 +18,16 @@ interface MainNavigationChatDetailRouter {
*/
interface MainNavigationCallDetailRouter {
fun exitDetailLocation()
fun goToCallDetail(location: MainNavigationDetailLocation.Calls)
fun goToCallDetail(location: MainDetailRoute.Calls)
}
/**
* Handles navigation to all [MainNavigationListLocation]s and [MainNavigationDetailLocation]s, including the top-level roots.
* Handles navigation to all [MainListRoute]s and [MainDetailRoute]s, including the top-level roots.
*/
interface MainNavigationRouter : MainNavigationChatDetailRouter, MainNavigationCallDetailRouter {
fun goTo(location: MainNavigationListLocation)
fun goTo(location: MainNavigationDetailLocation)
fun goTo(location: MainListRoute)
fun goTo(location: MainDetailRoute)
override fun goToChatDetail(location: MainNavigationDetailLocation.Chats) = goTo(location)
override fun goToCallDetail(location: MainNavigationDetailLocation.Calls) = goTo(location)
override fun exitDetailLocation() = goTo(MainNavigationDetailLocation.Empty)
override fun goToChatDetail(location: MainDetailRoute.Chats) = goTo(location)
override fun goToCallDetail(location: MainDetailRoute.Calls) = goTo(location)
}
@@ -5,12 +5,6 @@
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.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
@@ -18,31 +12,27 @@ import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.rx3.asObservable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.compose.split.ListDetailNavigator
import org.signal.core.ui.compose.split.PaneAnchor
import org.signal.core.ui.compose.split.exitDetail
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.calls.CallsBackStack
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.chats.ChatsBackStack
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -53,26 +43,24 @@ import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.stories.StoriesBackStack
import org.thoughtcrime.securesms.util.delegate
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
import java.util.Optional
import kotlin.time.Duration.Companion.milliseconds
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
class MainNavigationViewModel(
savedStateHandle: SavedStateHandle,
initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS
initialListLocation: MainListRoute = MainListRoute.Chats
) : ViewModel(), MainNavigationRouter {
companion object {
private val TAG = Log.tag(MainNavigationViewModel::class)
private const val LOCK_PANE_TO_SECONDARY = "lock_pane_to_secondary"
private const val NAV_PREFETCH_TIMEOUT_MS = 250L
private const val CHATS_BACK_STACK_KEY = "chats_back_stack_v2"
private const val CALLS_BACK_STACK_KEY = "calls_back_stack_v2"
private const val STORIES_BACK_STACK_KEY = "stories_back_stack_v2"
}
class Factory(
private val initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS
private val initialListLocation: MainListRoute = MainListRoute.Chats
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val savedStateHandle = extras.createSavedStateHandle()
@@ -83,45 +71,39 @@ class MainNavigationViewModel(
private val megaphoneRepository = AppDependencies.megaphoneRepository
private var navigator: AppScaffoldNavigator<Any>? = null
private var navigatorScope: CoroutineScope? = null
private var captureChatListSnapshot: (suspend () -> Unit)? = null
private var isSplitPane: Boolean = false
private val chatsBackStack: ChatsBackStack = ChatsBackStack(savedStateHandle)
val chatsBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
get() = chatsBackStack.entries
/**
* The stacks behind the main window, one per tab. The archive gets no stack of its own: it is a list
* pushed onto the chats stack, so that opening it keeps whatever chat was open beside it.
*/
val navigator = ListDetailNavigator<MainListRoute, MainDetailRoute>(
savedStateHandle = savedStateHandle,
scope = viewModelScope,
stackKeys = mapOf(
MainListRoute.Chats to CHATS_BACK_STACK_KEY,
MainListRoute.Calls to CALLS_BACK_STACK_KEY,
MainListRoute.Stories to STORIES_BACK_STACK_KEY
),
initialRoot = initialListLocation.tab
)
private val callsBackStack: CallsBackStack = CallsBackStack(savedStateHandle)
val callsBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
get() = callsBackStack.entries
/** The currently selected tab. */
val currentTab: StateFlow<MainListRoute> = navigator.currentRoot
private val storiesBackStack: StoriesBackStack = StoriesBackStack(savedStateHandle)
val storiesBackStackEntries: SnapshotStateList<MainNavigationDetailLocation>
get() = storiesBackStack.entries
/** How a split-pane window currently divides the list and detail panes. */
val paneAnchor: StateFlow<PaneAnchor> = navigator.paneAnchor
private val currentTabBackStack: MainDetailBackStack?
get() {
val currentListLocation = internalMainNavigationState.value.currentListLocation
return when {
currentListLocation.isChatsTab -> chatsBackStack
currentListLocation == MainNavigationListLocation.CALLS -> callsBackStack
currentListLocation == MainNavigationListLocation.STORIES -> storiesBackStack
else -> null
}
}
private val internalIsFullScreenPane = MutableStateFlow(false)
val isFullScreenPane: StateFlow<Boolean> = internalIsFullScreenPane
/** Whether one pane currently occupies the whole window. Only meaningful in split-pane layouts. */
val isFullScreenPane: StateFlow<Boolean> = navigator.isFullScreenPane
val observableActiveRecipientId: Observable<Optional<out RecipientId>> =
snapshotFlow { chatsBackStack.activeRecipientId }
navigator.snapshotsOf(MainListRoute.Chats) { activeRecipientId }
.combine(isFullScreenPane) { id, expanded -> if (expanded) Optional.ofNullable(null) else Optional.ofNullable(id) }
.asObservable()
val observableActiveCallId: Observable<Optional<out CallLogRow.Id>> =
snapshotFlow { callsBackStack.activeCallId }
navigator.snapshotsOf(MainListRoute.Calls) { activeCallId }
.combine(isFullScreenPane) { id, expanded -> if (expanded) Optional.ofNullable(null) else Optional.ofNullable(id) }
.asObservable()
@@ -133,56 +115,26 @@ class MainNavigationViewModel(
private val notificationProfilesRepository: NotificationProfilesRepository = NotificationProfilesRepository()
private val internalMainNavigationState = MutableStateFlow(MainNavigationState(currentListLocation = initialListLocation))
val mainNavigationState: StateFlow<MainNavigationState> = internalMainNavigationState
private val internalMainNavigationBarState = MutableStateFlow(MainNavigationBarState(currentListLocation = initialListLocation))
val mainNavigationBarState: StateFlow<MainNavigationBarState> = combine(internalMainNavigationBarState, navigator.displayedList) { state, listLocation ->
state.copy(currentListLocation = listLocation)
}.stateIn(viewModelScope, SharingStarted.Eagerly, MainNavigationBarState(currentListLocation = initialListLocation))
@OptIn(ExperimentalCoroutinesApi::class)
val detailLocation: StateFlow<MainNavigationDetailLocation> = mainNavigationState.flatMapLatest { state ->
when {
state.currentListLocation.isChatsTab -> {
snapshotFlow { chatsBackStack.entries.lastOrNull() ?: MainNavigationDetailLocation.Empty }
}
/**
* The detail content displayed above the current list, or null when the list is showing on its own.
*/
val detailLocation: StateFlow<MainDetailRoute?> = navigator.detail
state.currentListLocation == MainNavigationListLocation.CALLS -> {
snapshotFlow { callsBackStack.entries.lastOrNull() ?: MainNavigationDetailLocation.Empty }
}
state.currentListLocation == MainNavigationListLocation.STORIES -> {
snapshotFlow { storiesBackStack.entries.lastOrNull() ?: MainNavigationDetailLocation.Empty }
}
else -> flowOf(MainNavigationDetailLocation.Empty)
}
}.stateIn(viewModelScope, SharingStarted.Eagerly, MainNavigationDetailLocation.Empty)
/**
* Whether the current tab is displaying detail content.
*/
val hasDetailContent: StateFlow<Boolean> = navigator.hasDetail
/**
* This is Rx because these are still accessed from Java.
*/
private val internalTabClickEvents: MutableSharedFlow<MainNavigationListLocation> = MutableSharedFlow()
val tabClickEventsObservable: Observable<MainNavigationListLocation> = internalTabClickEvents.asObservable()
private var earlyNavigationListLocationRequested: MainNavigationListLocation? = null
private val internalPaneFocusRequests = MutableSharedFlow<ThreePaneScaffoldRole?>()
val paneFocusRequests: SharedFlow<ThreePaneScaffoldRole?> = internalPaneFocusRequests
private var earlyFocusedPaneRequested: ThreePaneScaffoldRole? = null
/**
* The navigator and its scope are owned by the MainActivity composition. That composition is disposed (and its scope
* cancelled) whenever the activity is recreated, but this view-model survives, so we can be left holding a navigator
* that can no longer do anything. Requests that arrive in that window have to be deferred until [wrapNavigator]
* hands us a live one, otherwise they're silently dropped.
*/
private val hasLiveNavigator: Boolean
get() = navigator != null && navigatorScope?.isActive == true
/**
* Which pane we display to the user at a given time should be driven solely by user intention. There are cases
* where the user can change configurations (such as opening a foldable) and we will restore state and errantly
* take them back into a PRIMARY pane. This boolean helps avoid these cases.
*/
private var lockPaneToSecondary: Boolean by savedStateHandle.delegate(LOCK_PANE_TO_SECONDARY, true)
private val internalTabClickEvents: MutableSharedFlow<MainListRoute> = MutableSharedFlow()
val tabClickEventsObservable: Observable<MainListRoute> = internalTabClickEvents.asObservable()
val snackbarRegistry = SnackbarStateConsumerRegistry()
@@ -204,83 +156,21 @@ class MainNavigationViewModel(
}
}
fun onPaneAnchorChanged(isFullScreenPane: Boolean) {
internalIsFullScreenPane.update { isFullScreenPane }
/**
* The user dragged the pane divider to [anchor].
*/
fun onPaneAnchorSelected(anchor: PaneAnchor) {
navigator.processEvent(ListDetailEvents.AnchorSelected(anchor))
}
fun setChatListSnapshotCaptureProvider(capture: suspend () -> Unit) {
/** Set from the MainActivity composition, and cleared when it is disposed. */
fun setChatListSnapshotCaptureProvider(capture: (suspend () -> Unit)?) {
captureChatListSnapshot = capture
}
fun onSplitPaneChanged(isSplitPane: Boolean) {
this@MainNavigationViewModel.isSplitPane = isSplitPane
override fun goTo(location: MainDetailRoute) = setDetailLocation(location)
if (!isSplitPane) {
if (currentTabBackStack?.isEmpty == true) {
lockPaneToSecondary = true
setFocusedPane(ThreePaneScaffoldRole.Secondary)
}
}
}
/**
* Sets the navigator on the view-model. This wraps the given navigator in our own delegating implementation
* such that we can react to navigateTo/Back signals and maintain proper state for internalDetailLocation.
*/
fun wrapNavigator(composeScope: CoroutineScope, threePaneScaffoldNavigator: ThreePaneScaffoldNavigator<Any>): AppScaffoldNavigator<Any> {
this.navigatorScope = composeScope
this.navigator = Nav(threePaneScaffoldNavigator)
val pendingFocus = earlyFocusedPaneRequested
earlyFocusedPaneRequested = null
earlyNavigationListLocationRequested?.let {
goTo(it)
}
earlyNavigationListLocationRequested = null
pendingFocus?.let { role ->
if (role == ThreePaneScaffoldRole.Primary) {
lockPaneToSecondary = false
}
setFocusedPane(role)
}
return this.navigator!!
}
fun setFocusedPane(role: ThreePaneScaffoldRole) {
val roleToGoTo = if (lockPaneToSecondary) {
ThreePaneScaffoldRole.Secondary
} else {
role
}
if (!hasLiveNavigator) {
earlyFocusedPaneRequested = roleToGoTo
return
}
navigatorScope?.launch {
navigator?.navigateTo(roleToGoTo)
}
viewModelScope.launch {
internalPaneFocusRequests.emit(roleToGoTo)
}
}
/**
* 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.
*
* This does not update what panel is currently focused, so that we can perform actions (such as first
* render) *before* swapping panes. This helps to prevent flashing / duplicate loads.
*/
override fun goTo(location: MainNavigationDetailLocation) = setDetailLocation(location)
private suspend fun MainNavigationDetailLocation.Conversation.withPreloadedWallpaper(): MainNavigationDetailLocation.Conversation {
private suspend fun MainDetailRoute.Conversation.withPreloadedWallpaper(): MainDetailRoute.Conversation {
val args = conversationArgs
val liveRecipient = Recipient.live(args.recipientId)
val recipientSnapshot = liveRecipient.get()
@@ -306,26 +196,26 @@ class MainNavigationViewModel(
return copy(conversationArgs = updatedArgs)
}
private fun setDetailLocation(location: MainNavigationDetailLocation) {
lockPaneToSecondary = false
val currentListLocation = internalMainNavigationState.value.currentListLocation
private fun setDetailLocation(location: MainDetailRoute) {
when (location) {
is MainNavigationDetailLocation.Empty if currentListLocation.isChatsTab -> clearDetailLocation(chatsBackStack)
is MainNavigationDetailLocation.Empty if currentListLocation == MainNavigationListLocation.CALLS -> clearDetailLocation(callsBackStack)
is MainNavigationDetailLocation.Empty if currentListLocation == MainNavigationListLocation.STORIES -> clearDetailLocation(storiesBackStack)
is MainNavigationDetailLocation.Chats -> pushChatsDetailLocation(location)
is MainNavigationDetailLocation.Conversation -> goToConversation(location)
is MainNavigationDetailLocation.Calls, is MainNavigationDetailLocation.CallLinkDetails -> pushCallsDetailLocation(location)
is MainNavigationDetailLocation.Stories -> pushStoriesDetailLocation(location)
is MainNavigationDetailLocation.Empty -> Unit
is MainDetailRoute.Chats -> pushChatsDetailLocation(location)
is MainDetailRoute.Conversation -> goToConversation(location)
is MainDetailRoute.Calls, is MainDetailRoute.CallLinkDetails -> pushCallsDetailLocation(location)
is MainDetailRoute.Stories -> pushStoriesDetailLocation(location)
}
}
private fun goToConversation(location: MainNavigationDetailLocation.Conversation) {
/**
* Drops the detail content above the current list, leaving that list displayed on its own.
*/
override fun exitDetailLocation() {
navigator.processEvent(ListDetailEvents.ExitDetail)
}
private fun goToConversation(location: MainDetailRoute.Conversation) {
val captureSnapshot = captureChatListSnapshot
if (captureSnapshot == null || !hasLiveNavigator) {
if (captureSnapshot == null) {
// share intent or process restore - push synchronously, since there's no chat-list snapshot to capture and no need to preload a wallpaper
pushChatsDetailLocation(location)
} else {
@@ -336,77 +226,42 @@ class MainNavigationViewModel(
}
}
private fun pushChatsDetailLocation(location: MainNavigationDetailLocation) {
if (location is MainNavigationDetailLocation.Chats && chatsBackStack.activeRecipientId != location.controllerKey) {
chatsBackStack.reset()
private fun pushChatsDetailLocation(location: MainDetailRoute) {
val chatsBackStack = navigator[MainListRoute.Chats]
if (location is MainDetailRoute.Chats && chatsBackStack.activeRecipientId != location.controllerKey) {
chatsBackStack.exitDetail()
}
chatsBackStack.push(location)
setFocusedPane(ThreePaneScaffoldRole.Primary)
navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Chats))
}
fun popChatsDetailLocation() = popDetailLocation(chatsBackStack)
private fun pushCallsDetailLocation(location: MainNavigationDetailLocation) {
if (location is MainNavigationDetailLocation.Calls && callsBackStack.activeCallId != location.controllerKey) {
callsBackStack.reset()
private fun pushCallsDetailLocation(location: MainDetailRoute) {
val callsBackStack = navigator[MainListRoute.Calls]
if (location is MainDetailRoute.Calls && callsBackStack.activeCallId != location.controllerKey) {
callsBackStack.exitDetail()
}
callsBackStack.push(location)
setFocusedPane(ThreePaneScaffoldRole.Primary)
navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Calls))
}
fun popCallsDetailLocation() = popDetailLocation(callsBackStack)
private fun pushStoriesDetailLocation(location: MainNavigationDetailLocation) {
storiesBackStack.push(location)
setFocusedPane(ThreePaneScaffoldRole.Primary)
private fun pushStoriesDetailLocation(location: MainDetailRoute) {
navigator.processEvent(ListDetailEvents.Push(location, MainListRoute.Stories))
}
fun popStoriesDetailLocation() = popDetailLocation(storiesBackStack)
private fun popDetailLocation(backStack: MainDetailBackStack) {
backStack.pop()
if (backStack.isEmpty) {
lockPaneToSecondary = true
popDetailPane()
}
/** Pops the stack belonging to whichever tab the user is currently on. */
fun popCurrentDetailLocation() {
navigator.processEvent(ListDetailEvents.Back)
}
private fun clearDetailLocation(backStack: MainDetailBackStack) {
backStack.reset()
if (!isSplitPane) {
lockPaneToSecondary = true
popDetailPane()
}
}
private fun popDetailPane() {
navigatorScope?.launch {
navigator?.let { scaffoldNavigator ->
if (scaffoldNavigator.canNavigateBack()) {
scaffoldNavigator.navigateBack()
}
}
}
viewModelScope.launch {
internalPaneFocusRequests.emit(ThreePaneScaffoldRole.Secondary)
}
}
override fun goTo(location: MainNavigationListLocation) {
lockPaneToSecondary = true
if (navigator == null) {
earlyNavigationListLocationRequested = location
return
}
internalMainNavigationState.update {
it.copy(currentListLocation = location)
}
/** Switching tabs only changes which stack is displayed; each tab comes back to whatever it had open. */
override fun goTo(location: MainListRoute) {
navigator.processEvent(
ListDetailEvents.GoToList(
listRoute = location,
root = location.tab,
push = location == MainListRoute.Archive
)
)
}
fun goToCameraFirstStoryCapture() {
@@ -436,7 +291,14 @@ class MainNavigationViewModel(
}
fun refreshNavigationBarState() {
internalMainNavigationState.update { it.copy(compact = SignalStore.settings.useCompactNavigationBar, isStoriesFeatureEnabled = Stories.isFeatureEnabled()) }
internalMainNavigationBarState.update {
it.copy(
compact = SignalStore.settings.useCompactNavigationBar,
destinations = MainNavigationBarState.ALL_DESTINATIONS.filter { destination ->
destination != MainListRoute.Stories || Stories.isFeatureEnabled()
}
)
}
}
fun getNotificationProfiles(): Flow<List<NotificationProfile>> {
@@ -444,38 +306,37 @@ class MainNavigationViewModel(
}
fun onChatsSelected() {
onTabSelected(MainNavigationListLocation.CHATS)
onTabSelected(MainListRoute.Chats)
}
fun onArchiveSelected() {
onTabSelected(MainNavigationListLocation.ARCHIVE)
onTabSelected(MainListRoute.Archive)
}
fun onCallsSelected() {
onTabSelected(MainNavigationListLocation.CALLS)
onTabSelected(MainListRoute.Calls)
}
fun onStoriesSelected() {
onTabSelected(MainNavigationListLocation.STORIES)
onTabSelected(MainListRoute.Stories)
}
private fun onTabSelected(destination: MainNavigationListLocation) {
private fun onTabSelected(destination: MainListRoute) {
viewModelScope.launch {
val currentTab = internalMainNavigationState.value.currentListLocation
if (currentTab == destination) {
internalPaneFocusRequests.emit(ThreePaneScaffoldRole.Secondary)
val displayed = navigator.displayedList.value
if (displayed == destination) {
navigator.processEvent(ListDetailEvents.RevealList)
internalTabClickEvents.emit(destination)
} else {
setFocusedPane(ThreePaneScaffoldRole.Secondary)
goTo(destination)
}
}
}
private fun <T : Any> performStoreUpdate(flow: Flow<T>, fn: (T, MainNavigationState) -> MainNavigationState) {
private fun <T : Any> performStoreUpdate(flow: Flow<T>, fn: (T, MainNavigationBarState) -> MainNavigationBarState) {
viewModelScope.launch {
flow.collectLatest { item ->
internalMainNavigationState.update { state -> fn(item, state) }
internalMainNavigationBarState.update { state -> fn(item, state) }
}
}
}
@@ -483,18 +344,4 @@ class MainNavigationViewModel(
enum class NavigationEvent {
STORY_CAMERA_FIRST
}
/**
* Ensures that when the user navigates back from the PRIMARY to SECONDARY pane, we lock our pane until they choose another primary
* piece of content via [goTo].
*/
private inner class Nav<T>(delegate: ThreePaneScaffoldNavigator<T>) : AppScaffoldNavigator<T>(delegate) {
override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean {
val result = super.navigateBack(backNavigationBehavior)
if (result) {
lockPaneToSecondary = true
}
return result
}
}
}
@@ -67,6 +67,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.DropdownMenus
@@ -152,13 +153,19 @@ enum class MainToolbarMode(val crossFadeKey: CrossFadeKey) {
FULL,
BASIC
}
/**
* The search bar is inset from the start edge of the list pane, so the pane it sits in has to be too.
*/
val listPaddingStart: Dp
get() = if (this == SEARCH) 24.dp else 0.dp
}
data class MainToolbarState(
val toolbarColor: Color? = null,
val self: Recipient = Recipient.UNKNOWN,
val mode: MainToolbarMode = MainToolbarMode.FULL,
val destination: MainNavigationListLocation = MainNavigationListLocation.CHATS,
val destination: MainListRoute = MainListRoute.Chats,
val chatFilter: ConversationFilter = ConversationFilter.OFF,
val callFilter: CallLogFilter = CallLogFilter.ALL,
val hasUnreadPayments: Boolean = false,
@@ -435,7 +442,7 @@ private fun PrimaryToolbar(
NotificationProfileAction(state, callback)
ProxyAction(state, callback)
if (state.destination == MainNavigationListLocation.STORIES && SignalStore.labs.storyArchive) {
if (state.destination == MainListRoute.Stories && SignalStore.labs.storyArchive) {
IconButtons.IconButton(
onClick = callback::onStoryArchiveClick
) {
@@ -471,10 +478,10 @@ private fun PrimaryToolbar(
controller = controller
) {
when (state.destination) {
MainNavigationListLocation.ARCHIVE -> Unit
MainNavigationListLocation.CHATS -> ChatDropdownItems(state, callback, dismiss)
MainNavigationListLocation.CALLS -> CallDropdownItems(state.callFilter, callback, dismiss)
MainNavigationListLocation.STORIES -> StoryDropDownItems(callback, dismiss)
MainListRoute.Archive -> Unit
MainListRoute.Chats -> ChatDropdownItems(state, callback, dismiss)
MainListRoute.Calls -> CallDropdownItems(state.callFilter, callback, dismiss)
MainListRoute.Stories -> StoryDropDownItems(callback, dismiss)
}
}
}
@@ -790,7 +797,7 @@ private fun FullMainToolbarPreview() {
state = MainToolbarState(
self = Recipient(isResolving = false),
mode = mode,
destination = MainNavigationListLocation.CHATS,
destination = MainListRoute.Chats,
hasEnabledNotificationProfile = true,
proxyState = MainToolbarState.ProxyState.CONNECTED,
hasFailedBackups = true,
@@ -83,19 +83,19 @@ class MainToolbarViewModel : ViewModel() {
fun isInActionMode(): Boolean = state.value.mode == MainToolbarMode.ACTION_MODE
fun presentToolbarForConversationListFragment() {
setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationListLocation.CHATS, overwriteExtraMode = false)
setToolbarMode(MainToolbarMode.FULL, destination = MainListRoute.Chats, overwriteExtraMode = false)
}
fun presentToolbarForConversationListArchiveFragment() {
setToolbarMode(MainToolbarMode.BASIC, destination = MainNavigationListLocation.CHATS)
setToolbarMode(MainToolbarMode.BASIC, destination = MainListRoute.Chats)
}
fun presentToolbarForStoriesLandingFragment() {
setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationListLocation.STORIES)
setToolbarMode(MainToolbarMode.FULL, destination = MainListRoute.Stories)
}
fun presentToolbarForCallLogFragment() {
setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationListLocation.CALLS)
setToolbarMode(MainToolbarMode.FULL, destination = MainListRoute.Calls)
}
fun presentToolbarForMultiselect() {
@@ -104,7 +104,7 @@ class MainToolbarViewModel : ViewModel() {
fun presentToolbarForCurrentDestination() {
when (state.value.destination) {
MainNavigationListLocation.ARCHIVE -> setToolbarMode(MainToolbarMode.BASIC)
MainListRoute.Archive -> setToolbarMode(MainToolbarMode.BASIC)
else -> setToolbarMode(MainToolbarMode.FULL)
}
}
@@ -112,7 +112,7 @@ class MainToolbarViewModel : ViewModel() {
@JvmOverloads
fun setToolbarMode(
mode: MainToolbarMode,
destination: MainNavigationListLocation? = null,
destination: MainListRoute? = null,
overwriteExtraMode: Boolean = true
) {
val previousMode = internalStateFlow.value.mode
@@ -48,14 +48,13 @@ import org.thoughtcrime.securesms.window.rememberAppScaffoldNavigator
@Composable
fun RecipientPickerScaffold(
title: String,
forceSplitPane: Boolean,
onNavigateUpClick: () -> Unit,
topAppBarActions: @Composable () -> Unit,
snackbarHostState: SnackbarHostState,
primaryContent: @Composable () -> Unit,
floatingActionButton: (@Composable () -> Unit)? = null
) {
val isSplitPane = LocalResources.current.rememberIsSplitPane(forceSplitPane)
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
AppScaffold(
@@ -133,7 +132,6 @@ private fun RecipientPickerScaffoldPreview() {
Previews.Preview {
RecipientPickerScaffold(
title = "Screen Title",
forceSplitPane = false,
onNavigateUpClick = {},
topAppBarActions = {},
snackbarHostState = SnackbarHostState(),
@@ -1,51 +0,0 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.stories
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewmodel.compose.SavedStateHandleSaveableApi
import androidx.lifecycle.viewmodel.compose.saveable
import org.thoughtcrime.securesms.main.MainDetailBackStack
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
/**
* Controls the navigation stack used by the stories screen.
*/
@OptIn(SavedStateHandleSaveableApi::class)
class StoriesBackStack(savedStateHandle: SavedStateHandle) : MainDetailBackStack {
companion object {
private const val KEY = "stories_back_stack"
val saver: Saver<SnapshotStateList<MainNavigationDetailLocation>, ArrayList<MainNavigationDetailLocation>> = Saver(
save = { ArrayList(it) },
restore = { mutableStateListOf(*it.toTypedArray()) }
)
}
override val entries: SnapshotStateList<MainNavigationDetailLocation> = savedStateHandle.saveable(
key = KEY,
saver = saver
) {
mutableStateListOf(MainNavigationDetailLocation.Empty)
}
override fun push(location: MainNavigationDetailLocation) {
when {
location is MainNavigationDetailLocation.Empty || location == entries.lastOrNull() -> Unit
location.isContentRoot -> {
entries.removeAll { it !is MainNavigationDetailLocation.Empty }
entries.add(location)
}
else -> entries.add(location)
}
}
}
@@ -16,47 +16,52 @@ 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.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey
import org.signal.core.ui.compose.split.detailEntry
import org.signal.core.ui.navigation.TransitionSpecs
import org.thoughtcrime.securesms.MainNavigator
import org.thoughtcrime.securesms.compose.FragmentBackHandler
import org.thoughtcrime.securesms.compose.FragmentBackPressedState
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.stories.archive.StoryArchiveScreen
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment
import org.thoughtcrime.securesms.stories.my.MyStoriesFragment
import org.thoughtcrime.securesms.stories.settings.StorySettingsNavHostFragment
fun EntryProviderScope<NavKey>.storiesNavEntries() {
entry<MainNavigationDetailLocation.Empty> {
NoStorySelectedEntry()
}
entry<MainNavigationDetailLocation.Stories.Archive>(
fun EntryProviderScope<NavKey>.registerStoriesTabDetailRoutes() {
detailEntry<MainDetailRoute.Stories.Archive>(
metadata = TransitionSpecs.None.metadata
) {
StoryArchiveEntry()
}
entry<MainNavigationDetailLocation.Stories.MyStories>(
detailEntry<MainDetailRoute.Stories.MyStories>(
metadata = TransitionSpecs.None.metadata
) {
MyStoriesEntry()
}
entry<MainNavigationDetailLocation.Stories.PrivacySettings>(
detailEntry<MainDetailRoute.Stories.PrivacySettings>(
metadata = TransitionSpecs.None.metadata
) {
StoryPrivacySettingsEntry()
}
}
/**
* List pane content for the stories tab.
*/
@Composable
private fun NoStorySelectedEntry() {
EmptyDetailScreen()
fun StoriesListPane(modifier: Modifier = Modifier) {
AndroidFragment(
clazz = StoriesLandingFragment::class.java,
fragmentState = rememberFragmentState(),
modifier = modifier
)
}
@Composable
@@ -71,28 +76,26 @@ private fun StoryArchiveEntry() {
@Composable
private fun MyStoriesEntry() {
val fragmentState = key(MainNavigationDetailLocation.Stories.MyStories) { rememberFragmentState() }
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
val fragmentState = key(MainDetailRoute.Stories.MyStories) { rememberFragmentState() }
informNavigatorWeAreReady()
// No FragmentBackHandler: the fragment has no back state of its own, so back belongs to the display
// that put this entry on the stack.
AndroidFragment(
clazz = MyStoriesFragment::class.java,
fragmentState = fragmentState,
modifier = androidx.compose.ui.Modifier
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
.navigationBarsPadding()
) { fragment ->
backPressedState.attach(fragment)
}
)
}
@Composable
private fun StoryPrivacySettingsEntry() {
val fragmentState = key(MainNavigationDetailLocation.Stories.PrivacySettings) { rememberFragmentState() }
val fragmentState = key(MainDetailRoute.Stories.PrivacySettings) { rememberFragmentState() }
val backPressedState = remember { FragmentBackPressedState() }
FragmentBackHandler(backPressedState)
@@ -101,7 +104,7 @@ private fun StoryPrivacySettingsEntry() {
AndroidFragment(
clazz = StorySettingsNavHostFragment::class.java,
fragmentState = fragmentState,
modifier = androidx.compose.ui.Modifier
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.statusBarsPadding()
@@ -4,7 +4,6 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.compose.ui.platform.ComposeView
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
@@ -33,11 +32,10 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainDetailRoute
import org.thoughtcrime.securesms.main.MainListRoute
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
import org.thoughtcrime.securesms.main.MainToolbarMode
import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
@@ -149,23 +147,8 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
}
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!closeSearchIfOpen()) {
if (mainNavigationViewModel.storiesBackStackEntries.last() != MainNavigationDetailLocation.Empty) {
mainNavigationViewModel.popStoriesDetailLocation()
} else {
mainNavigationViewModel.onChatsSelected()
}
}
}
}
)
lifecycleDisposable += mainNavigationViewModel.tabClickEventsObservable
.filter { it == MainNavigationListLocation.STORIES }
.filter { it == MainListRoute.Stories }
.subscribeBy(onNext = {
val layoutManager = recyclerView?.layoutManager as? LinearLayoutManager ?: return@subscribeBy
if (layoutManager.findFirstVisibleItemPosition() <= LIST_SMOOTH_SCROLL_TO_TOP_THRESHOLD) {
@@ -288,7 +271,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
private fun openStoryViewer(model: StoriesLandingItem.Model, preview: View, isFromInfoContextMenuAction: Boolean) {
if (model.data.storyRecipient.isMyStory) {
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Stories.MyStories)
mainNavigationViewModel.goTo(MainDetailRoute.Stories.MyStories)
} else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) {
if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) {
SafetyNumberBottomSheet
@@ -360,20 +343,4 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
viewModel.isTransitioningToAnotherScreen = true
startActivity(intent, options)
}
private fun isSearchOpen(): Boolean {
return isSearchVisible()
}
private fun isSearchVisible(): Boolean {
return mainToolbarViewModel.state.value.mode == MainToolbarMode.SEARCH
}
private fun closeSearchIfOpen(): Boolean {
if (isSearchOpen()) {
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
return true
}
return false
}
}
@@ -2,10 +2,8 @@ package org.thoughtcrime.securesms.stories.my
import android.net.Uri
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
@@ -19,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stories.StoryTextPostModel
@@ -37,8 +34,6 @@ class MyStoriesFragment : DSLSettingsFragment(
private val lifecycleDisposable = LifecycleDisposable()
private val mainNavigationViewModel: MainNavigationViewModel by activityViewModels()
private val viewModel: MyStoriesViewModel by viewModels(
factoryProducer = {
MyStoriesViewModel.Factory(MyStoriesRepository(requireContext()))
@@ -48,15 +43,6 @@ class MyStoriesFragment : DSLSettingsFragment(
override fun bindAdapter(adapter: MappingAdapter) {
MyStoriesItem.register(adapter)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
mainNavigationViewModel.popStoriesDetailLocation()
}
}
)
val emptyNotice = requireView().findViewById<View>(R.id.empty_notice)
lifecycleDisposable.bindTo(viewLifecycleOwner)
viewModel.state.observe(viewLifecycleOwner) {
@@ -48,8 +48,6 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.style.TextAlign
@@ -57,38 +55,16 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.BreakpointPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationBarState
import org.thoughtcrime.securesms.main.MainNavigationRail
import org.thoughtcrime.securesms.main.MainNavigationState
import kotlin.math.max
enum class NavigationType {
RAIL,
BAR;
companion object {
@Composable
fun rememberNavigationType(): NavigationType {
val resources = LocalResources.current
val config = LocalConfiguration.current
val windowBreakpoint = remember(config) { resources.getWindowBreakpoint() }
return when (windowBreakpoint) {
is WindowBreakpoint.Small -> BAR
is WindowBreakpoint.Medium -> if (windowBreakpoint.isWidthExpanded) RAIL else BAR
is WindowBreakpoint.Large -> RAIL
}
}
}
}
/**
* A top-level scaffold that automatically adapts its layout based on the device's window size class. It is a generic container designed to handle the
* arrangement of navigation rails, top/bottom bars, and list-detail pane management for both compact and large screens.
@@ -127,13 +103,7 @@ fun AppScaffold(
contentWindowInsets: WindowInsets = WindowInsets.systemBars,
animatorFactory: AppScaffoldAnimationStateFactory = AppScaffoldAnimationStateFactory.Default
) {
val isForceSinglePane = if (LocalInspectionMode.current) {
false
} else {
SignalStore.internal.forceSinglePane
}
val useSimpleScaffold = isForceSinglePane || (navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < 33)
val useSimpleScaffold = navigator.scaffoldDirective.maxHorizontalPartitions == 1 && Build.VERSION.SDK_INT < 33
if (useSimpleScaffold && LocalLayoutDirection.current != LayoutDirection.Rtl) {
SinglePaneAppScaffold(
navigator = navigator,
@@ -404,7 +374,7 @@ private fun ListAndNavigation(
private fun AppScaffoldPreview() {
Previews.Preview {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isSplitPane = LocalResources.current.rememberIsSplitPane(false)
val isSplitPane = LocalResources.current.rememberIsSplitPane()
AppScaffold(
navigator = rememberAppScaffoldNavigator(
@@ -440,14 +410,14 @@ private fun AppScaffoldPreview() {
},
navRailContent = {
MainNavigationRail(
state = MainNavigationState(),
state = MainNavigationBarState(),
mainFloatingActionButtonsCallback = MainFloatingActionButtonsCallback.Empty,
onDestinationSelected = {}
)
},
bottomNavContent = {
MainNavigationBar(
state = MainNavigationState(),
state = MainNavigationBarState(),
onDestinationSelected = {}
)
},
@@ -19,7 +19,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.window.core.layout.WindowSizeClass
@@ -27,7 +26,6 @@ import org.signal.core.ui.horizontalPartitionDefaultSpacerSize
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.listPaneDefaultPreferredWidth
import org.signal.core.ui.rememberIsSplitPane
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* AppScaffoldNavigator wraps a delegate navigator (such as the value returned by [rememberThreePaneScaffoldNavigatorDelegate]
@@ -105,9 +103,7 @@ open class AppScaffoldNavigator<T> @RememberInComposition constructor(private va
@Composable
fun rememberAppScaffoldNavigator(
windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass,
isSplitPane: Boolean = LocalResources.current.rememberIsSplitPane(
forceSplitPane = if (LocalInspectionMode.current) false else SignalStore.internal.forceSplitPane
),
isSplitPane: Boolean = LocalResources.current.rememberIsSplitPane(),
horizontalPartitionSpacerSize: Dp = windowSizeClass.horizontalPartitionDefaultSpacerSize,
defaultPanePreferredWidth: Dp = windowSizeClass.listPaneDefaultPreferredWidth
): AppScaffoldNavigator<Any> {
@@ -0,0 +1,87 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.app.Application
import androidx.lifecycle.SavedStateHandle
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.backStack
import org.signal.core.ui.compose.split.ListDetailBackStack
import org.signal.core.ui.compose.split.push
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* That a stack survives process death at all is covered by `ListDetailBackStackPersistenceTest` in
* `core:ui`. What is left here is that *these* routes survive it: every key is serialized by its concrete
* class, so each route class has to carry a serializer that round-trips its own arguments.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class MainDetailBackStackPersistenceTest {
private class Host(savedStateHandle: SavedStateHandle) {
val backStack: ListDetailBackStack by savedStateHandle.backStack("test_back_stack", MainListRoute.Chats)
}
/** Saves and restores the handle the way a process death would. */
private fun SavedStateHandle.roundTrip(): SavedStateHandle {
return SavedStateHandle.createHandle(savedStateProvider().saveState(), null)
}
@Test
fun `given a stacked detail location, when restored, then the whole stack comes back`() {
val handle = SavedStateHandle()
val roomId = CallLinkRoomId.fromBytes(byteArrayOf(7, 8, 9))
Host(handle).backStack.apply {
push(MainDetailRoute.CallLinkDetails(roomId))
push(MainDetailRoute.Calls.CallLinks.EditCallLinkName(roomId, "movie night"))
}
val restored = Host(handle.roundTrip()).backStack
assertEquals(
listOf(
MainListRoute.Chats,
MainDetailRoute.CallLinkDetails(roomId),
MainDetailRoute.Calls.CallLinks.EditCallLinkName(roomId, "movie night")
),
restored.toList()
)
}
@Test
fun `given a chats sub screen, when restored, then its arguments come back`() {
val handle = SavedStateHandle()
val recipientId = RecipientId.from(12)
Host(handle).backStack.push(MainDetailRoute.Chats.ConversationSettings(recipientId))
val restored = Host(handle.roundTrip()).backStack
assertEquals(
MainDetailRoute.Chats.ConversationSettings(recipientId),
restored.last()
)
}
/**
* [MainListRoute] is an enum, which `@Parcelize` cannot handle this is what lets the
* list locations sit at the root of a stack once they move into it.
*/
@Test
fun `given a list location on the stack, when restored, then the enum entry comes back`() {
val handle = SavedStateHandle()
Host(handle).backStack.push(MainListRoute.Archive)
val restored = Host(handle.roundTrip()).backStack
assertEquals(MainListRoute.Archive, restored.last())
}
}
@@ -0,0 +1,295 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import android.app.Application
import androidx.lifecycle.SavedStateHandle
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.compose.split.PaneAnchor
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
/**
* Covers the view model as the sole owner of the tab back stacks: everything a screen can ask for goes
* through [MainNavigationRouter] or [MainNavigationViewModel.popCurrentDetailLocation], and the stacks it
* hands the display are what those calls leave behind.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class MainNavigationViewModelTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
private val testDispatcher = StandardTestDispatcher()
private val conversationSettings = MainDetailRoute.Chats.ConversationSettings(RecipientId.from(1))
private val messageDetails = MainDetailRoute.Chats.MessageDetails(RecipientId.from(1), MessageId(2))
private val callLinkDetails = MainDetailRoute.CallLinkDetails(CallLinkRoomId.fromBytes(byteArrayOf(1)))
private lateinit var viewModel: MainNavigationViewModel
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
// Reached through NotificationProfilesRepository, which the view model builds but this suite never
// exercises.
mockkObject(SignalDatabase.Companion)
every { SignalDatabase.notificationProfiles } returns mockk()
mockkObject(MainNavigationRepository)
every { MainNavigationRepository.getNumberOfUnreadMessages() } returns emptyFlow()
every { MainNavigationRepository.getNumberOfUnseenCalls() } returns emptyFlow()
every { MainNavigationRepository.getNumberOfUnseenStories() } returns emptyFlow()
every { MainNavigationRepository.getHasFailedOutgoingStories() } returns emptyFlow()
viewModel = MainNavigationViewModel(SavedStateHandle())
testDispatcher.scheduler.advanceUntilIdle()
}
@After
fun tearDown() {
unmockkObject(MainNavigationRepository)
unmockkObject(SignalDatabase.Companion)
Dispatchers.resetMain()
}
@Test
fun `given a new view model, then chats is displayed with no detail`() {
assertEquals(MainListRoute.Chats, viewModel.currentTab.value)
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `when going to a detail location, then it is pushed onto the displayed tab`() {
viewModel.goTo(conversationSettings)
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given stacked detail, when popping, then only the top of the stack is dropped`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(messageDetails)
viewModel.popCurrentDetailLocation()
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given stacked detail, when exiting detail, then all of it is dropped`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(messageDetails)
viewModel.exitDetailLocation()
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given a stack at its root, when popping, then nothing is dropped`() {
viewModel.popCurrentDetailLocation()
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `when going to the archive, then it is pushed onto the chats stack rather than becoming a tab`() {
viewModel.goTo(MainListRoute.Archive)
assertEquals(MainListRoute.Chats, viewModel.currentTab.value)
assertEquals(
listOf(MainListRoute.Chats, MainListRoute.Archive),
viewModel.navigator[MainListRoute.Chats]
)
}
@Test
fun `given the archive is displayed, when going back to chats, then the archive is popped`() {
viewModel.goTo(MainListRoute.Archive)
viewModel.goTo(MainListRoute.Chats)
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
/**
* Leaving a conversation opened from the archive must not also close the archive, which is why detail
* content stacks above the displayed list rather than above the root.
*/
@Test
fun `given detail opened from the archive, when exiting detail, then the archive stays displayed`() {
viewModel.goTo(MainListRoute.Archive)
viewModel.goTo(conversationSettings)
viewModel.exitDetailLocation()
assertEquals(
listOf(MainListRoute.Chats, MainListRoute.Archive),
viewModel.navigator[MainListRoute.Chats]
)
}
@Test
fun `given detail is open, when opening the archive, then the detail stays displayed above it`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(MainListRoute.Archive)
assertEquals(
listOf(MainListRoute.Chats, MainListRoute.Archive, conversationSettings),
viewModel.navigator[MainListRoute.Chats]
)
}
@Test
fun `when going to a calls destination, then it is pushed onto the calls stack`() {
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
assertEquals(MainListRoute.Calls, viewModel.currentTab.value)
assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls])
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
}
/**
* Each tab keeps its own stack, so a tab that had detail content open comes back to it.
*/
@Test
fun `given detail open on another tab, when switching away and back, then that stack is unchanged`() {
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(MainListRoute.Calls)
assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls])
}
/**
* Popping and exiting act on whichever tab is displayed, not on a fixed one, so both are exercised from
* a tab other than chats.
*/
@Test
fun `given detail open on both tabs, when popping from calls, then only the calls stack is affected`() {
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(conversationSettings)
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.popCurrentDetailLocation()
assertEquals(listOf(MainListRoute.Calls), viewModel.navigator[MainListRoute.Calls])
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given detail open on both tabs, when exiting detail from calls, then only the calls stack is affected`() {
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(conversationSettings)
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.exitDetailLocation()
assertEquals(listOf(MainListRoute.Calls), viewModel.navigator[MainListRoute.Calls])
assertEquals(listOf(MainListRoute.Chats, conversationSettings), viewModel.navigator[MainListRoute.Chats])
}
@Test
fun `given detail open on another tab, when popping from chats, then only the chats stack is affected`() {
viewModel.goTo(MainListRoute.Calls)
viewModel.goTo(callLinkDetails)
viewModel.goTo(MainListRoute.Chats)
viewModel.goTo(conversationSettings)
viewModel.popCurrentDetailLocation()
assertEquals(listOf(MainListRoute.Chats), viewModel.navigator[MainListRoute.Chats])
assertEquals(listOf(MainListRoute.Calls, callLinkDetails), viewModel.navigator[MainListRoute.Calls])
}
@Test
fun `when going to a stories destination, then it is pushed onto the stories stack`() {
viewModel.goTo(MainListRoute.Stories)
viewModel.goTo(MainDetailRoute.Stories.MyStories)
assertEquals(
listOf(MainListRoute.Stories, MainDetailRoute.Stories.MyStories),
viewModel.navigator[MainListRoute.Stories]
)
}
/**
* Stories destinations are content roots, so opening one replaces whatever is already displayed rather
* than stacking on it.
*/
@Test
fun `given a stories destination is open, when opening another, then it replaces the first`() {
viewModel.goTo(MainListRoute.Stories)
viewModel.goTo(MainDetailRoute.Stories.MyStories)
viewModel.goTo(MainDetailRoute.Stories.PrivacySettings)
assertEquals(
listOf(MainListRoute.Stories, MainDetailRoute.Stories.PrivacySettings),
viewModel.navigator[MainListRoute.Stories]
)
}
@Test
fun `given the list fills the window, when detail content opens, then the detail is revealed`() {
viewModel.onPaneAnchorSelected(PaneAnchor.LIST_ONLY)
viewModel.goTo(conversationSettings)
assertEquals(PaneAnchor.DETAIL_ONLY, viewModel.paneAnchor.value)
}
@Test
fun `given the detail fills the window, when the last detail is popped, then the list is revealed`() {
viewModel.goTo(conversationSettings)
viewModel.onPaneAnchorSelected(PaneAnchor.DETAIL_ONLY)
viewModel.popCurrentDetailLocation()
assertEquals(PaneAnchor.LIST_ONLY, viewModel.paneAnchor.value)
}
@Test
fun `given stacked detail, when the top is popped, then the detail pane stays revealed`() {
viewModel.goTo(conversationSettings)
viewModel.goTo(messageDetails)
viewModel.onPaneAnchorSelected(PaneAnchor.DETAIL_ONLY)
viewModel.popCurrentDetailLocation()
assertEquals(PaneAnchor.DETAIL_ONLY, viewModel.paneAnchor.value)
}
}
+6
View File
@@ -30,6 +30,8 @@ dependencies {
api(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.runtime.compose)
api(libs.androidx.compose.ui.tooling.preview)
api(libs.androidx.activity.compose)
debugApi(libs.androidx.compose.ui.tooling.core)
@@ -40,6 +42,10 @@ dependencies {
api(libs.androidx.window.window)
api(libs.accompanist.permissions)
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.kotlinx.coroutines.test)
testImplementation(testLibs.robolectric.robolectric)
// JUnit is used by test fixtures
testFixturesImplementation(testLibs.junit.junit)
}
@@ -33,13 +33,9 @@ object CoreUiDependencies {
val isScreenSecurityEnabled: Boolean
get() = _provider.provideIsScreenSecurityEnabled()
val forceSplitPane: Boolean
get() = _provider.provideForceSplitPane()
interface Provider {
fun providePackageId(): String
fun provideIsIncognitoKeyboardEnabled(): Boolean
fun provideIsScreenSecurityEnabled(): Boolean
fun provideForceSplitPane(): Boolean
}
}
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import androidx.compose.runtime.Composable
/**
* Where a window's top-level navigation is placed: along the start edge, or across the bottom.
*/
enum class NavigationType {
RAIL,
BAR;
companion object {
@Composable
fun rememberNavigationType(): NavigationType = rememberWindowBreakpoint().navigationType
}
}
/**
* A window gets a rail once it is wide enough for one to sit beside the content rather than eat into it.
*/
val WindowBreakpoint.navigationType: NavigationType
get() = when (this) {
is WindowBreakpoint.Small -> NavigationType.BAR
is WindowBreakpoint.Medium -> if (isWidthExpanded) NavigationType.RAIL else NavigationType.BAR
is WindowBreakpoint.Large -> NavigationType.RAIL
}
@@ -0,0 +1,41 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.serialization.saved
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.serialization.NavBackStackSerializer
import androidx.navigation3.runtime.serialization.NavKeySerializer
import kotlin.properties.ReadWriteProperty
/**
* Creates a persistable backstack that can be owned by a ViewModel.
*/
fun SavedStateHandle.backStack(
key: String,
root: NavKey
): ReadWriteProperty<Any?, NavBackStack<NavKey>> {
return saved(
serializer = NavBackStackSerializer(NavKeySerializer()),
key = key
) {
NavBackStack(root)
}
}
/**
* The same persistable backstack as [backStack], for an owner that holds several of them and so cannot
* name each one as a property.
*/
fun SavedStateHandle.createBackStack(key: String, root: NavKey): NavBackStack<NavKey> {
return BackStackHolder(this, key, root).backStack
}
private class BackStackHolder(savedStateHandle: SavedStateHandle, key: String, root: NavKey) {
val backStack: NavBackStack<NavKey> by savedStateHandle.backStack(key, root)
}
@@ -126,25 +126,16 @@ val WindowBreakpoint.assumedFormFactor: FormFactor
}
@Composable
fun Resources.rememberIsSplitPane(
forceSplitPane: Boolean = CoreUiDependencies.forceSplitPane
): Boolean {
return remember(this, forceSplitPane) {
isSplitPane(forceSplitPane)
fun Resources.rememberIsSplitPane(): Boolean {
return remember(this) {
isSplitPane()
}
}
/**
* Determines whether the UI should display in split-pane mode based on available screen space.
*/
@JvmOverloads
fun Resources.isSplitPane(
forceSplitPane: Boolean = CoreUiDependencies.forceSplitPane
): Boolean {
if (forceSplitPane) {
return true
}
fun Resources.isSplitPane(): Boolean {
return when (val breakpoint = getWindowBreakpoint()) {
is WindowBreakpoint.Small -> false
is WindowBreakpoint.Medium -> true
@@ -0,0 +1,112 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
/**
* Describes a location that would sit in the "List" portion of a list-detail scaffold.
*/
interface ListNavKey : NavKey
/**
* Describes a location that would sit in the "Detail" portion of a list-detail scaffold.
*/
interface DetailNavKey : NavKey {
val isContentRoot: Boolean
}
/**
* A backstack for a list-detail scaffold. Allows us to decorate the type with a bunch of helpful extensions.
*/
typealias ListDetailBackStack = NavBackStack<NavKey>
/**
* Index of the list location currently being displayed. Everything above it is detail content.
*/
@PublishedApi
internal val ListDetailBackStack.listIndex: Int
get() = indexOfLast { it is ListNavKey }
/**
* The list location currently being displayed, which is the last one on the stack, as [L]. Throws if the
* displayed list is not an [L].
*/
inline fun <reified L : ListNavKey> ListDetailBackStack.listLocation(): L {
return this[listIndex] as L
}
/**
* The detail content displayed above the current list as [D], or null when the list is showing on its own.
*/
inline fun <reified D : DetailNavKey> ListDetailBackStack.detailLocation(): D? {
return lastOrNull() as? D
}
/**
* Whether detail content is displayed above the current list.
*/
val ListDetailBackStack.hasDetail: Boolean
get() = listIndex < lastIndex
/**
* Pushes [location] into the place its kind belongs
*/
fun ListDetailBackStack.push(location: NavKey) {
when (location) {
lastOrNull() -> Unit
is ListNavKey -> {
if (this[listIndex] != location) {
add(listIndex + 1, location)
}
}
is DetailNavKey if location.isContentRoot -> {
exitDetail()
add(location)
}
else -> add(location)
}
}
/**
* Drops the list locations stacked above [location] so that it becomes the displayed list, leaving the
* detail content above them in place. Does nothing if [location] is not on the stack.
*/
internal fun ListDetailBackStack.popToList(location: ListNavKey) {
if (!contains(location)) {
return
}
while (this[listIndex] != location) {
removeAt(listIndex)
}
}
/**
* Pops the top entry, whether that is detail content or a pushed list. Returns false at the root; an
* empty stack cannot be displayed.
*/
internal fun ListDetailBackStack.pop(): Boolean {
if (size <= 1) {
return false
}
removeAt(lastIndex)
return true
}
/**
* Drops everything above the current list location, leaving that list displayed with no detail. Any
* pushed list beneath it stays.
*/
fun ListDetailBackStack.exitDetail() {
while (size > listIndex + 1) {
removeAt(lastIndex)
}
}
@@ -0,0 +1,36 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.navigation3.runtime.NavKey
/**
* What a screen can ask of a [ListDetailNavigator]: the moves that are navigation and nothing else.
*/
sealed interface ListDetailEvents {
/** Push [location] onto [root]'s stack, or onto the displayed one when [root] is null. */
data class Push(val location: NavKey, val root: ListNavKey? = null) : ListDetailEvents
/** Display [listRoute], on [root]'s stack. See [ListDetailNavigator.goToList]. */
data class GoToList(
val listRoute: ListNavKey,
val root: ListNavKey = listRoute,
val push: Boolean = false
) : ListDetailEvents
/** Back, which drops the top of the displayed stack. */
data object Back : ListDetailEvents
/** Drop all the detail above the displayed list, leaving that list on its own. */
data object ExitDetail : ListDetailEvents
/** The user dragged the pane divider. */
data class AnchorSelected(val anchor: PaneAnchor) : ListDetailEvents
/** Reveal the list pane if the detail was filling the window, leaving the stacks alone. */
data object RevealList : ListDetailEvents
}
@@ -0,0 +1,120 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import org.signal.core.ui.navigation.TransitionSpecs
/**
* The decorated entries of whichever stack [navigator] is currently displaying, which is what [entries]
* wants.
*
* @param navigator the navigator whose stacks are displayed, from the view model that owns it.
* @param entryProvider builds the [NavEntry] for a key, from `entryProvider { }`.
*/
@Composable
fun rememberCurrentDecoratedNavEntries(
navigator: ListDetailNavigator<*, *>,
entryProvider: (NavKey) -> NavEntry<NavKey>
): List<NavEntry<NavKey>> {
val currentRoot by navigator.currentRoot.collectAsStateWithLifecycle()
var currentEntries: List<NavEntry<NavKey>>? = null
for ((root, stack) in navigator.stacks) {
// Each stack needs decorators of its own: they are where its state is kept, and sharing them would
// mean sharing that state.
val entries = key(root) {
rememberDecoratedNavEntries(
backStack = stack,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
),
entryProvider = entryProvider
)
}
if (root == currentRoot) {
currentEntries = entries
}
}
return checkNotNull(currentEntries) { "$currentRoot has no stack of its own." }
}
/**
* Boilerplate code that displays [entries] as a list/detail split, or as a single pane when [layout] is null.
*
* Wires up the scene strategy, the pane transitions, the geometry and chrome the scene reads, and the
* back handler for a detail pane filling the window.
*
* @param entries the stack to display. Which pane an entry lands in comes from how it was registered:
* [listEntry] for the list, [detailEntry] for the detail, and a plain `entry` for a screen that takes
* the whole window.
* @param isSplitPane whether the window is wide enough to show both panes. When false the entries are
* displayed one at a time, whatever [layout] says.
* @param paneAnchor where the divider currently sits, which decides whether back exits the detail pane.
* @param onBack called when the user wishes to navigate back, which should go through your view model.
* @param onExitDetail called when the user wishes to exit the detail pane, such as when the pane covers the whole screen.
* @param modifier passed through to NavDisplay
* @param layout the split geometry, from [rememberListDetailPaneLayout]. Null shows the list on its own.
* @param listPaneChrome extra content around the list that is static, it will not animate as the list changes.
* @param emptyDetailContent empty content when there's no detail specified.
*/
@Composable
fun ListDetailNavDisplay(
entries: List<NavEntry<NavKey>>,
isSplitPane: Boolean,
paneAnchor: PaneAnchor,
onBack: () -> Unit,
onExitDetail: () -> Unit,
modifier: Modifier = Modifier,
layout: ListDetailPaneLayout? = null,
listPaneChrome: ListPaneChrome? = null,
emptyDetailContent: @Composable () -> Unit = {}
) {
// A full-screen entry is above the detail pane rather than in it, so back pops it and leaves the anchor
// beneath alone.
val isFullScreen = entries.lastOrNull()?.isFullScreen == true
BackHandler(!isFullScreen && isSplitPane && paneAnchor == PaneAnchor.DETAIL_ONLY) {
onExitDetail()
}
val sceneStrategy = remember(isSplitPane) { ListDetailSceneStrategy(isSplitPane) }
val paneShift = TransitionSpecs.paneShift()
val paneShiftPop = TransitionSpecs.paneShift(pop = true)
CompositionLocalProvider(
LocalListDetailPaneLayout provides layout,
LocalListPaneChrome provides listPaneChrome,
LocalEmptyDetailContent provides emptyDetailContent
) {
NavDisplay(
entries = entries,
sceneStrategies = listOf(sceneStrategy),
transitionSpec = { paneShift },
popTransitionSpec = { paneShiftPop },
predictivePopTransitionSpec = { paneShiftPop },
onBack = { onBack() },
modifier = modifier
)
}
}
@@ -0,0 +1,184 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.runtime.snapshotFlow
import androidx.lifecycle.SavedStateHandle
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import org.signal.core.ui.createBackStack
/**
* This is the navigation's brain, and allows for managing multiple backstacks for a single nav display.
*
* Intended to be held by a view model, which keeps its own screens' rules and delegates the stack and
* pane bookkeeping here.
*
* @param savedStateHandle of the view-model who owns this object.
* @param scope of the view-model who owns this object.
* @param stackKeys one saved-state key per root, which is also the key the root's stack starts at. The
* keys are persisted, so changing one drops the stack it named.
* @param initialRoot the root displayed before anything navigates. Unlike the stacks themselves this is not
* persisted, so a restored navigator comes back here with each root's stack intact.
*/
class ListDetailNavigator<L : ListNavKey, D : DetailNavKey>(
savedStateHandle: SavedStateHandle,
scope: CoroutineScope,
stackKeys: Map<L, String>,
initialRoot: L
) {
/**
* Every root's stack, keyed by root. Created up front: allocating a stack inside [snapshotsOf]'s
* read-only snapshot would throw.
*/
val stacks: Map<L, ListDetailBackStack> = stackKeys.mapValues { (root, key) ->
savedStateHandle.createBackStack(key, root)
}
private val paneAnchorController = PaneAnchorController(savedStateHandle)
private val internalCurrentRoot = MutableStateFlow(initialRoot)
/** The root whose stack is currently displayed. */
val currentRoot: StateFlow<L> = internalCurrentRoot.asStateFlow()
/** How a split-pane window currently divides the two panes. Persisted. */
val paneAnchor: StateFlow<PaneAnchor> = paneAnchorController.anchor
/**
* Whether one pane currently occupies the whole window. Only meaningful in split-pane layouts;
* consumers that care about the single-pane case check the window size themselves.
*/
val isFullScreenPane: StateFlow<Boolean> = paneAnchorController.isFullScreenPane
.stateIn(scope, SharingStarted.Eagerly, false)
/** The list displayed by the current root's stack. */
@OptIn(ExperimentalCoroutinesApi::class)
val displayedList: StateFlow<L> = internalCurrentRoot
.flatMapLatest { root -> snapshotsOf(root) { listLocation() } }
.stateIn(scope, SharingStarted.Eagerly, currentStack.listLocation())
/** The detail content displayed above the current list, or null when the list is showing on its own. */
@OptIn(ExperimentalCoroutinesApi::class)
val detail: StateFlow<D?> = internalCurrentRoot
.flatMapLatest { root -> snapshotsOf(root) { detailLocation() } }
.stateIn(scope, SharingStarted.Eagerly, currentStack.detailLocation())
/** Whether the current root's stack is displaying detail content. */
val hasDetail: StateFlow<Boolean> = detail
.map { it != null }
.stateIn(scope, SharingStarted.Eagerly, false)
/** The stack belonging to [root]. */
operator fun get(root: L): ListDetailBackStack = stacks.getValue(root)
/** The stack currently displayed. */
private val currentStack: ListDetailBackStack
get() = this[internalCurrentRoot.value]
/**
* Observes [read] against [root]'s stack.
*/
fun <T> snapshotsOf(root: L, read: ListDetailBackStack.() -> T): Flow<T> {
val stack = this[root]
return snapshotFlow { stack.read() }
}
/**
* Applies [event] to these stacks.
*/
@Suppress("UNCHECKED_CAST")
fun processEvent(event: ListDetailEvents) {
when (event) {
is ListDetailEvents.Push -> push((event.root as L?) ?: internalCurrentRoot.value, event.location)
is ListDetailEvents.GoToList -> goToList(event.listRoute as L, event.root as L, event.push)
ListDetailEvents.Back -> popCurrentDetail()
ListDetailEvents.ExitDetail -> exitDetail()
is ListDetailEvents.AnchorSelected -> onAnchorSelected(event.anchor)
ListDetailEvents.RevealList -> revealList()
}
}
/** The user dragged the pane divider to [anchor]. */
private fun onAnchorSelected(anchor: PaneAnchor) {
paneAnchorController.onAnchorSelected(anchor)
}
/** Reveals the list pane if the detail was filling the window, leaving the stacks as they are. */
private fun revealList() {
paneAnchorController.revealListPane()
}
/**
* Pushes [location] onto [root]'s stack, wherever its kind belongs there.
*/
private fun push(root: L, location: NavKey) {
this[root].push(location)
if (location is DetailNavKey) {
paneAnchorController.revealDetailPane()
}
}
/**
* Drops the detail content above the current list, leaving that list displayed on its own.
*/
private fun exitDetail() {
currentStack.exitDetail()
paneAnchorController.revealListPane()
}
/**
* Pops the stack belonging to whichever root is displayed, revealing the list once the detail it was
* covering is gone.
*/
private fun popCurrentDetail() {
val stack = currentStack
stack.pop()
if (!stack.hasDetail) {
paneAnchorController.revealListPane()
}
}
/**
* Displays [listRoute], on the stack rooted at [root].
*
* @param listRoute the list to display.
* @param root the stack it lives on. Defaults to [listRoute] itself, which is the case for a root list.
* @param push whether to stack [listRoute] above the current list rather than returning to one already
* beneath it. Either way the detail content above stays where it is.
*/
private fun goToList(listRoute: L, root: L = listRoute, push: Boolean = false) {
val stack = this[root]
if (push) {
stack.push(listRoute)
} else {
stack.popToList(listRoute)
}
internalCurrentRoot.update { root }
paneAnchorController.revealListPane()
}
@Suppress("UNCHECKED_CAST")
private fun ListDetailBackStack.listLocation(): L = this[listIndex] as L
@Suppress("UNCHECKED_CAST")
private fun ListDetailBackStack.detailLocation(): D? = lastOrNull() as? D
}
@@ -0,0 +1,116 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.rememberIsSplitPane
/**
* Geometry for the list/detail split, provided to [ListDetailScene] through [LocalListDetailPaneLayout].
*/
@Stable
class ListDetailPaneLayout internal constructor(
internal val expansion: PaneExpansionState,
internal val partitionWidth: Dp,
internal val listPaddingStart: Dp,
internal val detailPaddingEnd: Dp,
internal val shape: Shape,
internal val hasDragHandle: Boolean,
internal val minPaneContentWidth: Dp
)
/**
* The current split geometry. A null layout means the display has no geometry to split with, and the
* scene falls back to showing the list on its own.
*/
internal val LocalListDetailPaneLayout = compositionLocalOf<ListDetailPaneLayout?> { null }
/**
* Builds the geometry for a list/detail split and keeps it following [paneAnchor], reporting a dragged
* divider back through [onAnchorSelected].
*
* Everything but the anchors comes from [metrics], so the usual call passes a [paneAnchor], a [maxWidth]
* and nothing else. Override [metrics] when a screen needs to depart from the baseline.
*
* @param paneAnchor the anchor the divider settles at.
* @param maxWidth the width available to both panes, from the `BoxWithConstraints` around them.
* @param onAnchorSelected the anchor a drag settled on, or that an accessibility action asked for.
* Note: The caller is expected to feed it back in through [paneAnchor], which is what
* actually moves the divider.
* @param metrics the baseline geometry, which decides the widths, the gutter and the corners.
* @param collapsedListWidth what is left of the list pane once the detail fills the window. Zero unless
* something inside the list stays on screen, such as a navigation rail.
*/
@Composable
fun rememberListDetailPaneLayout(
paneAnchor: PaneAnchor,
maxWidth: Dp,
onAnchorSelected: (PaneAnchor) -> Unit,
metrics: ListDetailPaneMetrics = rememberListDetailPaneMetrics(),
collapsedListWidth: Dp = 0.dp
): ListDetailPaneLayout {
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val splitWidth = metrics.rememberSplitListPaneWidth(maxWidth)
val anchorWidths = remember(collapsedListWidth, splitWidth, maxWidth, metrics) {
PaneAnchorWidths(
detailOnly = collapsedListWidth + metrics.listPaddingStart,
split = splitWidth,
listOnly = maxWidth - metrics.detailPaddingEnd
)
}
val density = LocalDensity.current
val anchorOffsets = remember(density, anchorWidths) { anchorWidths.toOffsets(density) }
val expansion = rememberPaneExpansionState(
initialOffsetPx = anchorOffsets.getValue(paneAnchor),
onAnchorSelected = onAnchorSelected
)
LaunchedEffect(anchorOffsets, paneAnchor) {
expansion.updateAnchors(anchorOffsets)
expansion.goTo(paneAnchor)
}
return remember(expansion, metrics, isSplitPane, splitWidth) {
ListDetailPaneLayout(
expansion = expansion,
partitionWidth = metrics.partitionWidth,
listPaddingStart = metrics.listPaddingStart,
detailPaddingEnd = metrics.detailPaddingEnd,
shape = metrics.shape,
hasDragHandle = isSplitPane,
minPaneContentWidth = splitWidth
)
}
}
/**
* Wraps list content in the static chrome that belongs to the list pane: navigation rail or bar, toolbar, and
* anything layered over them.
*
* Supplied through a composition local so that a scene can place it *around* the list entry, keeping one
* chrome instance alive while the list content swaps underneath.
*/
typealias ListPaneChrome = @Composable (content: @Composable () -> Unit) -> Unit
internal val LocalListPaneChrome = compositionLocalOf<ListPaneChrome?> { null }
/**
* Fills the detail pane when there is no detail content to show. Defaults to nothing, leaving the pane's
* own background.
*/
internal val LocalEmptyDetailContent = compositionLocalOf<@Composable () -> Unit> { {} }
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.isWidthExpanded
import org.signal.core.ui.rememberIsSplitPane
private val MEDIUM_CONTENT_CORNERS = 18.dp
private val EXTENDED_CONTENT_CORNERS = 14.dp
private val EXPANDED_LIST_PANE_WIDTH = 416.dp
/**
* The baseline geometry of a list/detail window: the values every Signal list/detail screen starts from,
* and which you hand to [rememberListDetailPaneLayout].
*
* A single pane gets square corners and no padding, since there is no second pane to separate it from.
*
* @property shape the shape both panes are clipped to.
* @property navigationBarShape the shape a navigation bar sitting at the bottom of the list pane is clipped
* to, which is [shape] with its top corners squared off.
* @property partitionWidth the gap between the two panes, where the drag handle sits.
* @property listPaddingStart padding between the window's start edge and the list pane.
* @property detailPaddingEnd padding between the detail pane and the window's end edge.
*/
@Immutable
data class ListDetailPaneMetrics(
val shape: Shape,
val navigationBarShape: Shape,
val partitionWidth: Dp,
val listPaddingStart: Dp,
val detailPaddingEnd: Dp
) {
private val extraPadding: Dp = partitionWidth + listPaddingStart + detailPaddingEnd
/**
* The list pane's width while both panes are visible, which is the [PaneAnchorWidths.split] anchor.
*
* A window wide enough gives the list a fixed width and lets the detail take the rest; a narrower one
* splits what is left over evenly. A single pane fills the window.
*
* @param maxWidth the width available to both panes, from the `BoxWithConstraints` around them.
*/
@Composable
fun rememberSplitListPaneWidth(maxWidth: Dp): Dp {
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
return remember(maxWidth, windowSizeClass, isSplitPane) {
when {
!isSplitPane -> maxWidth
windowSizeClass.isWidthExpanded -> EXPANDED_LIST_PANE_WIDTH
else -> (maxWidth - extraPadding) / 2f
}
}
}
}
/**
* The baseline metrics for the current window.
*
* @param listPaddingStart overrides the padding at the start of the list pane, for a caller that needs to
* inset the list itself. Ignored in a single-pane window, which has no padding.
*/
@Composable
fun rememberListDetailPaneMetrics(listPaddingStart: Dp = 0.dp): ListDetailPaneMetrics {
val resources = LocalResources.current
val breakpoint = resources.getWindowBreakpoint()
val isSplitPane = resources.rememberIsSplitPane()
return remember(breakpoint, isSplitPane, listPaddingStart) {
val corners = if (breakpoint is WindowBreakpoint.Large) EXTENDED_CONTENT_CORNERS else MEDIUM_CONTENT_CORNERS
if (!isSplitPane) {
ListDetailPaneMetrics(
shape = RectangleShape,
navigationBarShape = RectangleShape,
partitionWidth = 0.dp,
listPaddingStart = 0.dp,
detailPaddingEnd = 0.dp
)
} else {
ListDetailPaneMetrics(
shape = RoundedCornerShape(corners),
navigationBarShape = RoundedCornerShape(0.dp, 0.dp, corners, corners),
partitionWidth = if (breakpoint is WindowBreakpoint.Large) 24.dp else 13.dp,
listPaddingStart = listPaddingStart,
detailPaddingEnd = if (breakpoint is WindowBreakpoint.Large) 24.dp else 12.dp
)
}
}
}
@@ -0,0 +1,304 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
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.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import org.signal.core.ui.R
import kotlin.math.max
/**
* Renders a list entry and, when there is one, the detail entry above it side by side.
*
* There is some custom equality code in here to make sure animations are correct.
*/
@Immutable
internal class ListDetailScene(
override val key: Any,
override val previousEntries: List<NavEntry<NavKey>>,
private val listEntry: NavEntry<NavKey>,
private val detailEntry: NavEntry<NavKey>?
) : Scene<NavKey> {
override val entries: List<NavEntry<NavKey>> = listOfNotNull(listEntry, detailEntry)
override val content: @Composable () -> Unit = {
val layout = LocalListDetailPaneLayout.current
if (layout == null) {
listEntry.Content()
} else {
Box(modifier = Modifier.fillMaxSize()) {
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.width(layout.expansion.listWidth)
.fillMaxHeight()
.padding(start = layout.listPaddingStart)
.clip(layout.shape)
.minContentWidth(layout.minPaneContentWidth)
) {
ListPaneContent { listEntry.Content() }
}
Spacer(modifier = Modifier.width(layout.partitionWidth))
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(end = layout.detailPaddingEnd)
.clip(layout.shape)
.background(color = MaterialTheme.colorScheme.surface)
.minContentWidth(layout.minPaneContentWidth)
) {
if (detailEntry != null) {
detailEntry.Content()
} else {
LocalEmptyDetailContent.current()
}
}
}
if (layout.hasDragHandle) {
PaneDragHandle(layout = layout)
}
}
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ListDetailScene) return false
return key == other.key &&
listEntry == other.listEntry &&
detailEntry == other.detailEntry &&
previousEntries == other.previousEntries
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + listEntry.hashCode()
result = 31 * result + (detailEntry?.hashCode() ?: 0)
result = 31 * result + previousEntries.hashCode()
return result
}
}
/**
* Pane divider modeled after the one from the material libraries that allows the user to change the size of the list or detail pane.
*/
@Composable
private fun BoxScope.PaneDragHandle(layout: ListDetailPaneLayout) {
val interactionSource = remember { MutableInteractionSource() }
val density = LocalDensity.current
val dividerDescription = stringResource(R.string.ListDetailPane__accessibility_pane_divider)
val moveLabel = stringResource(R.string.ListDetailPane__accessibility_move_pane_divider)
val draggableState = rememberDraggableState { delta -> layout.expansion.dragBy(delta) }
val splitCenter = layout.expansion.listWidth + (layout.partitionWidth / 2)
val touchStart = splitCenter - (PANE_HANDLE_TOUCH_WIDTH / 2)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.CenterStart)
.offset { IntOffset(with(density) { touchStart.roundToPx() }, 0) }
.size(PANE_HANDLE_TOUCH_WIDTH, PANE_HANDLE_TOUCH_HEIGHT)
.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
interactionSource = interactionSource,
onDragStarted = { layout.expansion.onDragStarted() },
onDragStopped = { layout.expansion.settle() }
)
.semantics {
contentDescription = dividerDescription
onClick(label = moveLabel) {
val next = layout.expansion.nextAnchor()
if (next != null) {
layout.expansion.selectAnchor(next)
true
} else {
false
}
}
}
.systemGestureExclusion()
) {
Box(
modifier = Modifier
.size(PANE_HANDLE_VISUAL_WIDTH, PANE_HANDLE_VISUAL_HEIGHT)
.background(color = Color(0xFF605F5D), RoundedCornerShape(percent = 50))
)
}
}
/**
* Measures content at no less than [minWidth] while still reporting the slot's actual width. This makes sure that as the user
* makes a pane smaller and smaller the UI doesn't distort and look weird.
*/
private fun Modifier.minContentWidth(minWidth: Dp): Modifier {
return layout { measurable, constraints ->
val min = minWidth.roundToPx()
val placeable = measurable.measure(
constraints.copy(
minWidth = min,
maxWidth = max(min, constraints.maxWidth)
)
)
layout(constraints.maxWidth, placeable.height) {
placeable.placeRelative(x = 0, y = 0)
}
}
}
/**
* Renders [content] wrapped in the list pane chrome, if any has been provided.
*/
@Composable
private fun ListPaneContent(content: @Composable () -> Unit) {
val chrome = LocalListPaneChrome.current
if (chrome != null) {
chrome(content)
} else {
content()
}
}
/**
* Single-pane counterpart to [ListDetailScene]. We roll our own because otherwise animations get weird when swapping
* between lists.
*/
@Immutable
internal class SinglePaneScene(
override val key: Any,
override val previousEntries: List<NavEntry<NavKey>>,
private val entry: NavEntry<NavKey>,
private val isList: Boolean
) : Scene<NavKey> {
override val entries: List<NavEntry<NavKey>> = listOf(entry)
override val content: @Composable () -> Unit = {
if (isList) {
ListPaneContent { entry.Content() }
} else {
entry.Content()
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SinglePaneScene) return false
return key == other.key &&
entry == other.entry &&
isList == other.isList &&
previousEntries == other.previousEntries
}
override fun hashCode(): Int {
var result = key.hashCode()
result = 31 * result + entry.hashCode()
result = 31 * result + isList.hashCode()
result = 31 * result + previousEntries.hashCode()
return result
}
}
/**
* Renders the given backstack depending on what kind of screen we're on. Note that we do utilize fixed keys here because we want to make
* sure that as we move between screens in either pane we are *only* animating that panes content and not the whole scene.
*/
internal class ListDetailSceneStrategy(private val isSplitPane: Boolean) : SceneStrategy<NavKey> {
override fun SceneStrategyScope<NavKey>.calculateScene(entries: List<NavEntry<NavKey>>): Scene<NavKey>? {
val top = entries.lastOrNull() ?: return null
if (top.isFullScreen) {
return SinglePaneScene(
key = top.contentKey,
previousEntries = entries.dropLast(1),
entry = top,
isList = false
)
}
if (!isSplitPane) {
val isList = top.isListPane
return SinglePaneScene(
key = if (isList) LIST_SCENE_KEY else top.contentKey,
previousEntries = entries.dropLast(1),
entry = top,
isList = isList
)
}
val listIndex = entries.indexOfLast { it.isListPane }
if (listIndex < 0) {
return null
}
val detailEntries = entries.subList(listIndex + 1, entries.size)
return ListDetailScene(
key = DETAIL_SCENE_KEY,
previousEntries = if (detailEntries.size > 1) entries.dropLast(1) else entries.take(listIndex),
listEntry = entries[listIndex],
detailEntry = detailEntries.lastOrNull()
)
}
}
/** Fixed detail key */
private const val DETAIL_SCENE_KEY = "signal.listDetail"
/** Fixed list key */
private const val LIST_SCENE_KEY = "signal.list"
@@ -0,0 +1,48 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
/**
* How a split-pane window divides the list and detail panes.
*/
enum class PaneAnchor {
/** The detail pane fills the window, with the list pushed off the start edge. */
DETAIL_ONLY,
/** Both panes are visible. */
SPLIT,
/** The list fills the window, with the detail pane pushed off the end edge. */
LIST_ONLY
}
/**
* The width the list pane takes at each [PaneAnchor].
*
* @property detailOnly width left to the list once the detail fills the window. Not necessarily zero: a
* navigation rail living inside the list pane stays on screen.
* @property split width of the list pane while both panes are visible.
* @property listOnly width the list grows to once it fills the window, which leaves room for whatever
* padding the detail pane's edge needs.
*/
data class PaneAnchorWidths(
val detailOnly: Dp,
val split: Dp,
val listOnly: Dp
) {
internal fun toOffsets(density: Density): Map<PaneAnchor, Float> {
return with(density) {
mapOf(
PaneAnchor.DETAIL_ONLY to detailOnly.toPx(),
PaneAnchor.SPLIT to split.toPx(),
PaneAnchor.LIST_ONLY to listOnly.toPx()
)
}
}
}
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
/**
* Owns which [PaneAnchor] a split-pane window sits at, and the rules for moving between them. Intended
* to be held by the view-model that owns the window's navigation state.
*/
internal class PaneAnchorController(private val savedStateHandle: SavedStateHandle) {
companion object {
private const val KEY = "pane_anchor"
}
val anchor: StateFlow<PaneAnchor> = savedStateHandle.getStateFlow(KEY, PaneAnchor.SPLIT)
/**
* Whether one pane currently occupies the whole window.
*/
val isFullScreenPane: Flow<Boolean> = anchor.map { it != PaneAnchor.SPLIT }
/**
* The user dragged the pane divider to [anchor].
*/
fun onAnchorSelected(anchor: PaneAnchor) {
set(anchor)
}
/**
* Opening detail content while the list fills the window has to reveal the detail. A window already
* showing both panes is left as the user arranged it.
*/
fun revealDetailPane() {
if (anchor.value == PaneAnchor.LIST_ONLY) {
set(PaneAnchor.DETAIL_ONLY)
}
}
/**
* Losing detail content, or explicitly asking for the list, has to reveal the list if the detail
* filled the window.
*/
fun revealListPane() {
if (anchor.value == PaneAnchor.DETAIL_ONLY) {
set(PaneAnchor.LIST_ONLY)
}
}
private fun set(value: PaneAnchor) {
savedStateHandle[KEY] = value
}
}
@@ -0,0 +1,82 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.NavMetadataKey
import androidx.navigation3.runtime.contains
import androidx.navigation3.runtime.metadata
/**
* Marks an entry as list-pane content, so that a scene can tell which of the entries it has been handed
* belongs in the list pane, since it can't gather that information for itself via a key which is private.
*/
internal object ListPane : NavMetadataKey<Boolean>
/**
* Marks an entry as detail-pane content, the counterpart to [ListPane].
*/
internal object DetailPane : NavMetadataKey<Boolean>
/**
* Registers [content] as the list pane for [K]. The counterpart to `entry` for a list location, and the
* only way to mark one.
*
* @param metadata additional metadata for the display, such as a transition spec.
*/
inline fun <reified K : ListNavKey> EntryProviderScope<NavKey>.listEntry(
metadata: Map<String, Any> = emptyMap(),
noinline content: @Composable (K) -> Unit
) {
entry<K>(metadata = metadata + listPaneMetadata(), content = content)
}
/**
* Registers [content] as the detail pane for [K], displayed beside the list in a split-pane window and
* over it in a single-pane one.
*
* @param metadata additional metadata for the display, such as a transition spec.
*/
inline fun <reified K : DetailNavKey> EntryProviderScope<NavKey>.detailEntry(
metadata: Map<String, Any> = emptyMap(),
noinline content: @Composable (K) -> Unit
) {
entry<K>(metadata = metadata + detailPaneMetadata(), content = content)
}
/**
* Metadata marking an entry as list-pane content, applied by [listEntry].
*/
@PublishedApi
internal fun listPaneMetadata(): Map<String, Any> = metadata { put(ListPane, true) }
/**
* Metadata marking an entry as detail-pane content, applied by [detailEntry].
*/
@PublishedApi
internal fun detailPaneMetadata(): Map<String, Any> = metadata { put(DetailPane, true) }
/**
* Whether this entry belongs in the list pane.
*/
internal val NavEntry<*>.isListPane: Boolean
get() = ListPane in metadata
/**
* Whether this entry belongs in the detail pane.
*/
internal val NavEntry<*>.isDetailPane: Boolean
get() = DetailPane in metadata
/**
* Whether this entry takes the whole window. An entry that claims neither pane gets both, which is what a
* plain `entry` registration means.
*/
internal val NavEntry<*>.isFullScreen: Boolean
get() = !isListPane && !isDetailPane
@@ -0,0 +1,130 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.animation.core.animate
import androidx.compose.foundation.MutatorMutex
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
/**
* Drives how wide the list pane is, in pixels.
*/
@Stable
internal class PaneExpansionState(
private val density: Density,
initialOffsetPx: Float,
private val onAnchorSelected: (PaneAnchor) -> Unit
) {
private var offsetPx by mutableFloatStateOf(initialOffsetPx)
private var anchorOffsets: Map<PaneAnchor, Float> by mutableStateOf(emptyMap())
private val animationMutex = MutatorMutex()
private var isDragging = false
/** Current width of the list pane. */
val listWidth: Dp
get() = with(density) { offsetPx.toDp() }
/** Publishes the widths each anchor corresponds to, from layout. */
fun updateAnchors(offsets: Map<PaneAnchor, Float>) {
anchorOffsets = offsets
}
/**
* Applies a drag delta.
*/
fun dragBy(delta: Float) {
val min = anchorOffsets.values.minOrNull() ?: return
val max = anchorOffsets.values.maxOrNull() ?: return
offsetPx = (offsetPx + delta).coerceIn(min, max)
}
fun onDragStarted() {
isDragging = true
}
/**
* Settles onto whichever anchor the drag ended nearest, and reports it as a selection.
*/
suspend fun settle() {
isDragging = false
val nearest = anchorOffsets.minByOrNull { abs(it.value - offsetPx) }?.key ?: return
onAnchorSelected(nearest)
animateTo(nearest)
}
/**
* Moves the split to [anchor], ignored while a drag is in progress so that a state change mid-gesture
* doesn't yank the divider out from under the user's finger.
*/
suspend fun goTo(anchor: PaneAnchor) {
if (isDragging) {
return
}
animateTo(anchor)
}
/**
* The anchor an accessibility action should move to next, cycling through them in declared order. Screen
* readers cannot drag, so this is how the divider is operated without a gesture.
*/
fun nextAnchor(): PaneAnchor? {
val current = anchorOffsets.minByOrNull { abs(it.value - offsetPx) }?.key ?: return null
val anchors = PaneAnchor.entries
return anchors[(anchors.indexOf(current) + 1) % anchors.size]
}
/**
* Reports [anchor] as selected without dragging to it. The resulting state change is what moves the
* divider, by the same path a drag takes.
*/
fun selectAnchor(anchor: PaneAnchor) {
onAnchorSelected(anchor)
}
private suspend fun animateTo(anchor: PaneAnchor) {
val target = anchorOffsets[anchor] ?: return
animationMutex.mutate {
animate(initialValue = offsetPx, targetValue = target) { value, _ -> offsetPx = value }
}
}
}
/** [initialOffsetPx] is the width the list pane starts at, read once on the composition that creates this. */
@Composable
internal fun rememberPaneExpansionState(initialOffsetPx: Float, onAnchorSelected: (PaneAnchor) -> Unit): PaneExpansionState {
val density = LocalDensity.current
return remember(density) { PaneExpansionState(density, initialOffsetPx, onAnchorSelected) }
}
/** Touch width of the pane divider, which is wider than both the visual handle and the gap it sits in. */
internal val PANE_HANDLE_TOUCH_WIDTH: Dp = 48.dp
/** Width of the visible pill, which tracks the divider rather than the touch target. */
internal val PANE_HANDLE_VISUAL_WIDTH: Dp = 4.dp
internal val PANE_HANDLE_VISUAL_HEIGHT: Dp = 48.dp
/** Height of the grab area. Bounded rather than full-height so the system gesture exclusion is honoured. */
internal val PANE_HANDLE_TOUCH_HEIGHT: Dp = 48.dp
@@ -8,6 +8,7 @@ package org.signal.core.ui.navigation
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -18,6 +19,14 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.metadata
import androidx.navigation3.ui.NavDisplay
@@ -26,6 +35,10 @@ import androidx.navigation3.ui.NavDisplay
*/
object TransitionSpecs {
private const val PANE_SHIFT_DURATION = 200
private val PANE_SHIFT_OFFSET = 48.dp
private val PANE_SHIFT_EASING = CubicBezierEasing(0.4f, 0.0f, 0.2f, 1f)
interface Transition {
companion object {
val NONE: ContentTransform = EnterTransition.None togetherWith ExitTransition.None
@@ -48,6 +61,54 @@ object TransitionSpecs {
}
}
/**
* [paneShift] against the density and layout direction of wherever it is called, which is what a nav
* display wants. Every display that uses this reads the same, whether it fills a pane or the window.
*
* @param pop reverses the direction, for navigating back.
*/
@Composable
fun paneShift(pop: Boolean = false): ContentTransform {
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
return remember(density, layoutDirection, pop) { paneShift(density, layoutDirection, pop) }
}
/**
* The short horizontal shift a pane makes as it enters or leaves: content moves [PANE_SHIFT_OFFSET]
* rather than a whole width, cross-fading as it goes, with no scale.
*
* A function rather than a [Transition] value because the distance is fixed in dp, so it needs a
* [Density]. Values mirror `AppScaffoldAnimationDefaults`, which cannot be referenced from here.
*
* @param pop reverses the direction, for navigating back.
*/
fun paneShift(density: Density, layoutDirection: LayoutDirection, pop: Boolean = false): ContentTransform {
val offset = with(density) { PANE_SHIFT_OFFSET.roundToPx() }
val direction = if (layoutDirection == LayoutDirection.Rtl) -1 else 1
val sign = if (pop) -1 else 1
val slideSpec = tween<IntOffset>(durationMillis = PANE_SHIFT_DURATION, easing = PANE_SHIFT_EASING)
val fadeSpec = tween<Float>(durationMillis = PANE_SHIFT_DURATION, easing = PANE_SHIFT_EASING)
return slideInHorizontally(animationSpec = slideSpec) { offset * sign * direction } + fadeIn(animationSpec = fadeSpec) togetherWith
slideOutHorizontally(animationSpec = slideSpec) { -offset * sign * direction } + fadeOut(animationSpec = fadeSpec)
}
/**
* Suppresses only the *enter* transition, leaving pops to the display's defaults.
*
* For destinations that animate their own arrival and would otherwise be animated twice a conversation
* hands off from a bitmap of the list it came from but which should still animate on the way out. Using
* [None] here would suppress both directions, because a pop is resolved against the metadata of the
* scene being left.
*/
val suppressEnterMetadata: Map<String, Any> get() = metadata {
put(NavDisplay.TransitionKey) {
Transition.NONE
}
}
/**
* No enter/exit animation.
*/
+6
View File
@@ -18,6 +18,12 @@
<!-- Title for dialog shown when a required permission has not been granted -->
<string name="Permissions_permission_required">Permission required</string>
<!-- ListDetailPane -->
<!-- Content description for the draggable divider between the list and detail panes on large screens -->
<string name="ListDetailPane__accessibility_pane_divider">Resize panes</string>
<!-- Accessibility action label for moving the divider between the list and detail panes to its next position -->
<string name="ListDetailPane__accessibility_move_pane_divider">Move divider</string>
<!-- StorageUtil -->
<!-- Format string for displaying a storage path as volume/filename -->
<string name="StorageUtil__s_s">%1$s/%2$s</string>
@@ -0,0 +1,50 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui
import org.junit.Assert.assertEquals
import org.junit.Test
class NavigationTypeTest {
@Test
fun `given a small window, then navigation is a bar`() {
assertEquals(NavigationType.BAR, WindowBreakpoint.Small(isWidthExpanded = false, isHeightExpanded = false).navigationType)
}
/**
* A small window is a bar however wide it reports itself: a compact height is enough to rule out a rail,
* and that is the case a landscape phone lands in.
*/
@Test
fun `given a small window that is wide, then navigation is still a bar`() {
assertEquals(NavigationType.BAR, WindowBreakpoint.Small(isWidthExpanded = true, isHeightExpanded = false).navigationType)
}
@Test
fun `given a medium window that is not wide, then navigation is a bar`() {
assertEquals(NavigationType.BAR, WindowBreakpoint.Medium(isWidthExpanded = false, isHeightExpanded = true).navigationType)
}
@Test
fun `given a medium window that is wide, then navigation is a rail`() {
assertEquals(NavigationType.RAIL, WindowBreakpoint.Medium(isWidthExpanded = true, isHeightExpanded = true).navigationType)
}
@Test
fun `given a large window, then navigation is a rail`() {
assertEquals(NavigationType.RAIL, WindowBreakpoint.Large(isWidthExpanded = true, isHeightExpanded = true).navigationType)
}
/**
* A tablet in portrait is not width-expanded but is still a large window, which is what separates it from
* the medium case above.
*/
@Test
fun `given a large window that is not wide, then navigation is still a rail`() {
assertEquals(NavigationType.RAIL, WindowBreakpoint.Large(isWidthExpanded = false, isHeightExpanded = true).navigationType)
}
}
@@ -0,0 +1,107 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import android.app.Application
import androidx.lifecycle.SavedStateHandle
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.ui.backStack
/**
* A stack owned by a [SavedStateHandle] is persisted through `kotlinx.serialization` rather than
* `Parcelable`, via `NavKeySerializer`, which writes each entry's concrete class name and reflects the
* serializer back on restore. That only fails at runtime, and only after process death, so it is worth
* covering directly.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class ListDetailBackStackPersistenceTest {
private class Host(savedStateHandle: SavedStateHandle) {
val backStack: ListDetailBackStack by savedStateHandle.backStack("test_back_stack", TestListKey.ROOT)
}
/** Saves and restores the handle the way a process death would. */
private fun SavedStateHandle.roundTrip(): SavedStateHandle {
return SavedStateHandle.createHandle(savedStateProvider().saveState(), null)
}
@Test
fun `given a new stack, when restored, then it is still at its root`() {
val handle = SavedStateHandle()
Host(handle).backStack
val restored = Host(handle.roundTrip()).backStack
assertEquals(listOf(TestListKey.ROOT), restored.toList())
}
@Test
fun `given a stacked detail location, when restored, then the whole stack comes back`() {
val handle = SavedStateHandle()
Host(handle).backStack.apply {
push(TestDetailKey(7))
push(TestSubScreenKey(7, "movie night"))
}
val restored = Host(handle.roundTrip()).backStack
assertEquals(
listOf(TestListKey.ROOT, TestDetailKey(7), TestSubScreenKey(7, "movie night")),
restored.toList()
)
}
/**
* A list key is free to be an enum, which `@Parcelize` cannot handle this is what lets list locations
* sit at the root of a stack.
*/
@Test
fun `given a list location on the stack, when restored, then the enum entry comes back`() {
val handle = SavedStateHandle()
Host(handle).backStack.push(TestListKey.PUSHED)
val restored = Host(handle.roundTrip()).backStack
assertEquals(TestListKey.PUSHED, restored.last())
}
@Test
fun `given a stack mixing list and detail keys, when restored, then order and types are preserved`() {
val handle = SavedStateHandle()
Host(handle).backStack.apply {
push(TestDetailKey(34))
push(TestListKey.PUSHED)
}
val restored = Host(handle.roundTrip()).backStack
assertEquals(
listOf(TestListKey.ROOT, TestListKey.PUSHED, TestDetailKey(34)),
restored.toList()
)
}
/**
* The stack is a snapshot state list, so a change made after the first save has to be written by the
* next one rather than the handle holding on to the state it was given.
*/
@Test
fun `given a stack that changed since the last save, when restored, then the change is included`() {
val handle = SavedStateHandle()
val backStack = Host(handle).backStack
handle.roundTrip()
backStack.push(TestDetailKey(1))
val restored = Host(handle.roundTrip()).backStack
assertEquals(listOf(TestListKey.ROOT, TestDetailKey(1)), restored.toList())
}
}
@@ -0,0 +1,261 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.navigation3.runtime.NavBackStack
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ListDetailBackStackTest {
private val contentRoot = TestDetailKey(1)
private val otherContentRoot = TestDetailKey(2)
private val subScreen = TestSubScreenKey(1)
private fun backStack(root: TestListKey = TestListKey.ROOT): ListDetailBackStack {
return NavBackStack(root)
}
@Test
fun `given a new stack, then it displays its root list with no detail`() {
val backStack = backStack()
assertEquals(TestListKey.ROOT, backStack.listLocation<TestListKey>())
assertFalse(backStack.hasDetail)
}
@Test
fun `given a stack with only a list, when pushing detail, then detail is displayed above it`() {
val backStack = backStack()
backStack.push(contentRoot)
assertTrue(backStack.hasDetail)
assertEquals(TestListKey.ROOT, backStack.listLocation<TestListKey>())
assertEquals(listOf(TestListKey.ROOT, contentRoot), backStack.toList())
}
@Test
fun `given detail is displayed, when pushing another content root, then it replaces the previous detail`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(subScreen)
backStack.push(otherContentRoot)
assertEquals(listOf(TestListKey.ROOT, otherContentRoot), backStack.toList())
}
@Test
fun `given detail is displayed, when pushing a sub screen, then it stacks on top`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(subScreen)
assertEquals(listOf(TestListKey.ROOT, contentRoot, subScreen), backStack.toList())
}
@Test
fun `given an entry on top, when pushing that same entry, then it is not duplicated`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(contentRoot)
assertEquals(listOf(TestListKey.ROOT, contentRoot), backStack.toList())
}
@Test
fun `given a list is displayed, when pushing another list, then that list is displayed`() {
val backStack = backStack()
backStack.push(TestListKey.PUSHED)
assertEquals(TestListKey.PUSHED, backStack.listLocation<TestListKey>())
assertFalse(backStack.hasDetail)
}
@Test
fun `given a list is already displayed, when pushing it again, then it is not duplicated`() {
val backStack = backStack()
backStack.push(TestListKey.ROOT)
assertEquals(listOf(TestListKey.ROOT), backStack.toList())
}
/**
* Pushing a list while detail is open must not blank the detail pane, so the list is inserted beneath
* the detail content rather than on top of it.
*/
@Test
fun `given detail is displayed, when pushing a list, then the detail stays displayed`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(TestListKey.PUSHED)
assertEquals(TestListKey.PUSHED, backStack.listLocation<TestListKey>())
assertTrue(backStack.hasDetail)
assertEquals(
listOf(TestListKey.ROOT, TestListKey.PUSHED, contentRoot),
backStack.toList()
)
}
/**
* Because lists sit beneath detail content, back closes the detail before leaving the list it was
* opened from.
*/
@Test
fun `given detail over a pushed list, when popping, then the detail closes before the list`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(TestListKey.PUSHED)
backStack.pop()
assertEquals(TestListKey.PUSHED, backStack.listLocation<TestListKey>())
assertFalse(backStack.hasDetail)
backStack.pop()
assertEquals(TestListKey.ROOT, backStack.listLocation<TestListKey>())
}
@Test
fun `given a pushed list, when popping to the list beneath it, then that list is displayed`() {
val backStack = backStack()
backStack.push(TestListKey.PUSHED)
backStack.popToList(TestListKey.ROOT)
assertEquals(listOf(TestListKey.ROOT), backStack.toList())
}
@Test
fun `given detail over a pushed list, when popping to the list beneath it, then the detail stays displayed`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(TestListKey.PUSHED)
backStack.popToList(TestListKey.ROOT)
assertEquals(TestListKey.ROOT, backStack.listLocation<TestListKey>())
assertTrue(backStack.hasDetail)
assertEquals(listOf(TestListKey.ROOT, contentRoot), backStack.toList())
}
@Test
fun `given the displayed list is already the target, when popping to it, then the stack is unchanged`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.popToList(TestListKey.ROOT)
assertEquals(listOf(TestListKey.ROOT, contentRoot), backStack.toList())
}
/**
* Every stack keeps its own root list, so a list belonging to another stack is not on it. Stripping
* lists in search of one would leave nothing to display.
*/
@Test
fun `given a location that is not on the stack, when popping to it, then the stack is unchanged`() {
val backStack = backStack()
backStack.push(TestListKey.PUSHED)
backStack.push(contentRoot)
backStack.popToList(TestListKey.OTHER)
assertEquals(
listOf(TestListKey.ROOT, TestListKey.PUSHED, contentRoot),
backStack.toList()
)
}
@Test
fun `given a pushed list, when popped, then the previous list is displayed again`() {
val backStack = backStack()
backStack.push(TestListKey.PUSHED)
assertTrue(backStack.pop())
assertEquals(TestListKey.ROOT, backStack.listLocation<TestListKey>())
}
@Test
fun `given a stack at its root, when popped, then nothing is removed`() {
val backStack = backStack()
assertFalse(backStack.pop())
assertEquals(listOf(TestListKey.ROOT), backStack.toList())
}
/**
* Handing an empty list to a NavDisplay throws, so popping must never drain the stack no matter how
* many times it is called.
*/
@Test
fun `given a deep stack, when popped past the root, then the stack is never empty`() {
val backStack = backStack()
backStack.push(TestListKey.PUSHED)
backStack.push(contentRoot)
backStack.push(subScreen)
repeat(10) { backStack.pop() }
assertTrue(backStack.isNotEmpty())
assertEquals(listOf(TestListKey.ROOT), backStack.toList())
}
@Test
fun `given stacked detail, when exiting detail, then only the detail is dropped`() {
val backStack = backStack()
backStack.push(contentRoot)
backStack.push(subScreen)
backStack.exitDetail()
assertFalse(backStack.hasDetail)
assertEquals(listOf(TestListKey.ROOT), backStack.toList())
}
@Test
fun `given detail opened from a pushed list, when exiting detail, then the pushed list stays`() {
val backStack = backStack()
backStack.push(TestListKey.PUSHED)
backStack.push(contentRoot)
backStack.exitDetail()
assertEquals(TestListKey.PUSHED, backStack.listLocation<TestListKey>())
assertFalse(backStack.hasDetail)
assertEquals(listOf(TestListKey.ROOT, TestListKey.PUSHED), backStack.toList())
}
@Test
fun `given no detail, when exiting detail, then the stack is unchanged`() {
val backStack = backStack()
backStack.exitDetail()
assertEquals(listOf(TestListKey.ROOT), backStack.toList())
}
@Test(expected = ClassCastException::class)
fun `given a list key of another type, when reading the list location, then it throws`() {
val backStack: ListDetailBackStack = NavBackStack(OtherListKey)
backStack.listLocation<TestListKey>()
}
private object OtherListKey : ListNavKey
}
@@ -0,0 +1,235 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import android.app.Application
import androidx.compose.runtime.snapshots.Snapshot
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* Robolectric because the stacks are owned by a [SavedStateHandle], which stores them in a `Bundle`.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class ListDetailNavigatorTest {
private val detail = TestDetailKey(1)
private val otherDetail = TestDetailKey(2)
private val subScreen = TestSubScreenKey(1)
private fun TestScope.navigator(initialRoot: TestListKey = TestListKey.ROOT): ListDetailNavigator<TestListKey, DetailNavKey> {
return ListDetailNavigator(
savedStateHandle = SavedStateHandle(),
scope = backgroundScope,
stackKeys = mapOf(
TestListKey.ROOT to "root_stack",
TestListKey.OTHER to "other_stack"
),
initialRoot = initialRoot
)
}
@Test
fun `given a new navigator, then each root has its own stack`() = runTest {
val navigator = navigator()
assertEquals(listOf(TestListKey.ROOT), navigator[TestListKey.ROOT])
assertEquals(listOf(TestListKey.OTHER), navigator[TestListKey.OTHER])
assertEquals(TestListKey.ROOT, navigator.currentRoot.value)
}
@Test
fun `when pushing detail, then it lands on that root's stack and the detail pane is revealed`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.LIST_ONLY))
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
assertEquals(listOf(TestListKey.ROOT, detail), navigator[TestListKey.ROOT])
assertEquals(PaneAnchor.DETAIL_ONLY, navigator.paneAnchor.value)
}
/**
* A root that is not displayed can still be pushed to a deep link arriving for another root leaves it
* waiting there rather than switching the window to it.
*/
@Test
fun `when pushing detail onto another root, then the displayed stack is untouched`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.OTHER))
assertEquals(listOf(TestListKey.ROOT), navigator[TestListKey.ROOT])
assertEquals(listOf(TestListKey.OTHER, detail), navigator[TestListKey.OTHER])
assertEquals(TestListKey.ROOT, navigator.currentRoot.value)
}
@Test
fun `when exiting detail, then the current stack drops it and the list pane is revealed`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))
navigator.processEvent(ListDetailEvents.ExitDetail)
assertEquals(listOf(TestListKey.ROOT), navigator[TestListKey.ROOT])
assertEquals(PaneAnchor.LIST_ONLY, navigator.paneAnchor.value)
}
/**
* Popping one of several detail entries leaves detail on screen, so the pane it is displayed in has to
* stay as the user left it.
*/
@Test
fun `given stacked detail, when popping, then the list pane is not revealed`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.Push(subScreen, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))
navigator.processEvent(ListDetailEvents.Back)
assertEquals(listOf(TestListKey.ROOT, detail), navigator[TestListKey.ROOT])
assertEquals(PaneAnchor.DETAIL_ONLY, navigator.paneAnchor.value)
}
@Test
fun `given one detail, when popping, then the list pane is revealed`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))
navigator.processEvent(ListDetailEvents.Back)
assertEquals(PaneAnchor.LIST_ONLY, navigator.paneAnchor.value)
}
@Test
fun `when going to another root, then it is displayed and the list pane is revealed`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.OTHER))
assertEquals(TestListKey.OTHER, navigator.currentRoot.value)
assertEquals(PaneAnchor.LIST_ONLY, navigator.paneAnchor.value)
}
/**
* Each root keeps its own stack, so a root that had detail open comes back to it rather than to its
* list.
*/
@Test
fun `given detail open on a root, when leaving and returning to it, then the detail is still there`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.OTHER))
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.ROOT))
assertEquals(listOf(TestListKey.ROOT, detail), navigator[TestListKey.ROOT])
}
@Test
fun `when pushing a list onto a root, then it is displayed above that root`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.PUSHED, root = TestListKey.ROOT, push = true))
assertEquals(listOf(TestListKey.ROOT, TestListKey.PUSHED), navigator[TestListKey.ROOT])
assertEquals(TestListKey.ROOT, navigator.currentRoot.value)
}
@Test
fun `given a pushed list, when going back to its root, then the pushed list is dropped`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.PUSHED, root = TestListKey.ROOT, push = true))
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.ROOT))
assertEquals(listOf(TestListKey.ROOT), navigator[TestListKey.ROOT])
}
@Test
fun `when revealing the list, then the pane moves without the stack changing`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))
navigator.processEvent(ListDetailEvents.RevealList)
assertEquals(PaneAnchor.LIST_ONLY, navigator.paneAnchor.value)
assertEquals(listOf(TestListKey.ROOT, detail), navigator[TestListKey.ROOT])
}
@Test
fun `given a split window, then no pane is full screen`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.SPLIT))
runCurrent()
assertFalse(navigator.isFullScreenPane.value)
navigator.processEvent(ListDetailEvents.AnchorSelected(PaneAnchor.DETAIL_ONLY))
runCurrent()
assertTrue(navigator.isFullScreenPane.value)
}
@Test
fun `when the displayed stack changes, then the displayed list and detail follow it`() = runTest {
val navigator = navigator()
runCurrent()
assertEquals(TestListKey.ROOT, navigator.displayedList.value)
assertNull(navigator.detail.value)
assertFalse(navigator.hasDetail.value)
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
settle()
assertEquals(detail, navigator.detail.value)
assertTrue(navigator.hasDetail.value)
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.PUSHED, root = TestListKey.ROOT, push = true))
settle()
assertEquals(TestListKey.PUSHED, navigator.displayedList.value)
}
@Test
fun `when moving to another root, then the displayed detail is that root's`() = runTest {
val navigator = navigator()
navigator.processEvent(ListDetailEvents.Push(detail, TestListKey.ROOT))
navigator.processEvent(ListDetailEvents.Push(otherDetail, TestListKey.OTHER))
settle()
assertEquals(detail, navigator.detail.value)
navigator.processEvent(ListDetailEvents.GoToList(TestListKey.OTHER))
settle()
assertEquals(otherDetail, navigator.detail.value)
assertEquals(TestListKey.OTHER, navigator.displayedList.value)
}
/** Publishes snapshot writes to `snapshotFlow`, then lets the flows collecting them run. */
private fun TestScope.settle() {
Snapshot.sendApplyNotifications()
runCurrent()
}
}
@@ -0,0 +1,210 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategyScope
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Covers what a scene reports as its previous entries, because that is the whole of the display's back
* behaviour: `NavDisplay` enables its back handler when a scene has previous entries, and pops the
* difference between the stack and them. Both are reproduced here by [isBackEnabled] and [popsOnBack].
*/
class ListDetailSceneStrategyTest {
private val rootEntry = listEntry(TestListKey.ROOT)
private val pushedEntry = listEntry(TestListKey.PUSHED)
private val otherRootEntry = listEntry(TestListKey.OTHER)
private val detailEntry = detailEntry(TestDetailKey(1))
private val subScreenEntry = detailEntry(TestSubScreenKey(1))
private val fullScreenEntry = plainEntry(TestFullScreenKey)
@Test
fun `given split pane showing a list on its own, then back is left to the rest of the app`() {
val entries = listOf(rootEntry)
assertFalse(splitPane(entries).isBackEnabled)
}
/**
* The empty detail pane is not a destination: closing the last piece of detail content would leave the
* user on the same list looking at a placeholder, so back is left to the handlers outside the display.
*/
@Test
fun `given split pane showing a list and one detail, then back is left to the rest of the app`() {
val entries = listOf(rootEntry, detailEntry)
assertFalse(splitPane(entries).isBackEnabled)
}
@Test
fun `given split pane showing stacked detail, when going back, then only the top detail is popped`() {
val entries = listOf(rootEntry, detailEntry, subScreenEntry)
val scene = splitPane(entries)
assertTrue(scene.isBackEnabled)
assertEquals(1, scene.popsOnBack(entries))
assertEquals(listOf(rootEntry, detailEntry), scene.previousEntries)
}
@Test
fun `given split pane showing a pushed list, when going back, then that list is popped`() {
val entries = listOf(rootEntry, pushedEntry)
val scene = splitPane(entries)
assertTrue(scene.isBackEnabled)
assertEquals(1, scene.popsOnBack(entries))
assertEquals(listOf(rootEntry), scene.previousEntries)
}
/**
* Backing out of a pushed list with detail open takes the detail with it, rather than stopping on the
* pushed list with an empty detail pane.
*/
@Test
fun `given split pane showing detail over a pushed list, when going back, then both are popped`() {
val entries = listOf(rootEntry, pushedEntry, detailEntry)
val scene = splitPane(entries)
assertTrue(scene.isBackEnabled)
assertEquals(2, scene.popsOnBack(entries))
assertEquals(listOf(rootEntry), scene.previousEntries)
}
@Test
fun `given split pane, then it displays the current list beside the topmost detail`() {
val scene = splitPane(listOf(rootEntry, pushedEntry, detailEntry, subScreenEntry))
assertEquals(listOf(pushedEntry, subScreenEntry), scene.entries)
}
/**
* One scene identity for every list, so that swapping between them is not a scene change and the
* display does not cross-fade the whole window.
*/
@Test
fun `given split pane, then every list shares one scene identity`() {
val root = splitPane(listOf(rootEntry))
val other = splitPane(listOf(otherRootEntry))
assertEquals(root.key, other.key)
}
@Test
fun `given a single pane showing a list on its own, then back is left to the rest of the app`() {
assertFalse(singlePane(listOf(rootEntry)).isBackEnabled)
}
/**
* The single-pane counterpart of the split-pane case above: here the detail covers the list rather than
* sitting beside it, so closing it is a real destination.
*/
@Test
fun `given a single pane showing detail, when going back, then the detail is popped`() {
val entries = listOf(rootEntry, detailEntry)
val scene = singlePane(entries)
assertTrue(scene.isBackEnabled)
assertEquals(1, scene.popsOnBack(entries))
}
@Test
fun `given a single pane showing detail over a pushed list, when going back, then the list stays`() {
val entries = listOf(rootEntry, pushedEntry, detailEntry)
val scene = singlePane(entries)
assertEquals(1, scene.popsOnBack(entries))
assertEquals(listOf(rootEntry, pushedEntry), scene.previousEntries)
}
@Test
fun `given a single pane, then it displays only the top entry`() {
val scene = singlePane(listOf(rootEntry, detailEntry))
assertEquals(listOf(detailEntry), scene.entries)
}
@Test
fun `given a single pane, then lists share an identity that detail content does not`() {
val root = singlePane(listOf(rootEntry))
val other = singlePane(listOf(otherRootEntry))
val detail = singlePane(listOf(rootEntry, detailEntry))
assertEquals(root.key, other.key)
assertNotEquals(root.key, detail.key)
}
@Test
fun `given split pane with a full screen entry on top, then it is displayed instead of the panes`() {
val scene = splitPane(listOf(rootEntry, detailEntry, fullScreenEntry))
assertEquals(listOf(fullScreenEntry), scene.entries)
}
@Test
fun `given a full screen entry on top, when going back, then only it is popped`() {
val entries = listOf(rootEntry, detailEntry, fullScreenEntry)
listOf(splitPane(entries), singlePane(entries)).forEach { scene ->
assertTrue(scene.isBackEnabled)
assertEquals(1, scene.popsOnBack(entries))
assertEquals(listOf(rootEntry, detailEntry), scene.previousEntries)
}
}
/**
* The list keeps one scene identity so that swapping lists does not cross-fade the window; a full screen
* entry is a window of its own, and moving to it should be a scene change.
*/
@Test
fun `given a full screen entry, then it does not share the list scene identity`() {
val list = splitPane(listOf(rootEntry))
val fullScreen = splitPane(listOf(rootEntry, fullScreenEntry))
assertNotEquals(list.key, fullScreen.key)
}
private fun splitPane(entries: List<NavEntry<NavKey>>): Scene<NavKey> = scene(entries, isSplitPane = true)
private fun singlePane(entries: List<NavEntry<NavKey>>): Scene<NavKey> = scene(entries, isSplitPane = false)
private fun scene(entries: List<NavEntry<NavKey>>, isSplitPane: Boolean): Scene<NavKey> {
return with(ListDetailSceneStrategy(isSplitPane)) {
SceneStrategyScope<NavKey>().calculateScene(entries)
}!!
}
/** What `NavDisplay` uses to decide whether to register a back handler at all. */
private val Scene<NavKey>.isBackEnabled: Boolean
get() = previousEntries.isNotEmpty()
/** What `NavDisplay` pops when its back handler completes. */
private fun Scene<NavKey>.popsOnBack(entries: List<NavEntry<NavKey>>): Int = entries.size - previousEntries.size
private fun listEntry(location: ListNavKey): NavEntry<NavKey> {
return NavEntry(key = location, metadata = listPaneMetadata()) {}
}
private fun detailEntry(location: DetailNavKey): NavEntry<NavKey> {
return NavEntry(key = location, metadata = detailPaneMetadata()) {}
}
/** An entry registered with a plain `entry`, claiming neither pane. */
private fun plainEntry(location: NavKey): NavEntry<NavKey> {
return NavEntry(key = location) {}
}
}
@@ -0,0 +1,120 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PaneAnchorControllerTest {
private fun controller(initial: PaneAnchor? = null): PaneAnchorController {
val controller = PaneAnchorController(SavedStateHandle())
if (initial != null) {
controller.onAnchorSelected(initial)
}
return controller
}
@Test
fun `given a fresh controller, then both panes are shown`() {
assertEquals(PaneAnchor.SPLIT, controller().anchor.value)
}
@Test
fun `given a drag settles on an anchor, then that anchor is selected`() {
val controller = controller()
controller.onAnchorSelected(PaneAnchor.DETAIL_ONLY)
assertEquals(PaneAnchor.DETAIL_ONLY, controller.anchor.value)
}
@Test
fun `given the list fills the window, when detail content opens, then the detail is revealed`() {
val controller = controller(PaneAnchor.LIST_ONLY)
controller.revealDetailPane()
assertEquals(PaneAnchor.DETAIL_ONLY, controller.anchor.value)
}
/**
* A window already showing both panes is an arrangement the user chose; opening a conversation in the
* detail pane must not collapse the list out from under them.
*/
@Test
fun `given both panes are shown, when detail content opens, then the arrangement is left alone`() {
val controller = controller(PaneAnchor.SPLIT)
controller.revealDetailPane()
assertEquals(PaneAnchor.SPLIT, controller.anchor.value)
}
@Test
fun `given the detail already fills the window, when detail content opens, then it stays filled`() {
val controller = controller(PaneAnchor.DETAIL_ONLY)
controller.revealDetailPane()
assertEquals(PaneAnchor.DETAIL_ONLY, controller.anchor.value)
}
@Test
fun `given the detail fills the window, when the list is revealed, then the list fills the window`() {
val controller = controller(PaneAnchor.DETAIL_ONLY)
controller.revealListPane()
assertEquals(PaneAnchor.LIST_ONLY, controller.anchor.value)
}
@Test
fun `given both panes are shown, when the list is revealed, then the arrangement is left alone`() {
val controller = controller(PaneAnchor.SPLIT)
controller.revealListPane()
assertEquals(PaneAnchor.SPLIT, controller.anchor.value)
}
@Test
fun `given the list already fills the window, when the list is revealed, then it stays filled`() {
val controller = controller(PaneAnchor.LIST_ONLY)
controller.revealListPane()
assertEquals(PaneAnchor.LIST_ONLY, controller.anchor.value)
}
@Test
fun `given both panes are shown, then neither pane is full screen`() = runTest {
assertFalse(controller(PaneAnchor.SPLIT).isFullScreenPane.first())
}
@Test
fun `given one pane fills the window, then it is reported as full screen`() = runTest {
assertTrue(controller(PaneAnchor.DETAIL_ONLY).isFullScreenPane.first())
assertTrue(controller(PaneAnchor.LIST_ONLY).isFullScreenPane.first())
}
/**
* The anchor is persisted so that it survives a configuration change without the layout having to
* re-derive it.
*/
@Test
fun `given a selected anchor, when rebuilt from the same saved state, then the anchor is restored`() {
val savedStateHandle = SavedStateHandle()
PaneAnchorController(savedStateHandle).onAnchorSelected(PaneAnchor.LIST_ONLY)
assertEquals(PaneAnchor.LIST_ONLY, PaneAnchorController(savedStateHandle).anchor.value)
}
}
@@ -0,0 +1,153 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.compose.runtime.MonotonicFrameClock
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PaneExpansionStateTest {
private val anchorOffsets = mapOf(
PaneAnchor.DETAIL_ONLY to 100f,
PaneAnchor.SPLIT to 500f,
PaneAnchor.LIST_ONLY to 900f
)
private val selected = mutableListOf<PaneAnchor>()
private fun expansion(initialOffsetPx: Float = 500f, withAnchors: Boolean = true): PaneExpansionState {
return PaneExpansionState(Density(2f), initialOffsetPx) { selected += it }.apply {
if (withAnchors) {
updateAnchors(anchorOffsets)
}
}
}
@Test
fun `given an initial offset, then the list is that wide at this density`() {
assertEquals(250.dp, expansion(initialOffsetPx = 500f).listWidth)
}
/**
* The layout publishes the anchors from measurement, which happens after construction. A drag arriving
* first has nothing to clamp against, so it is dropped rather than moving the divider anywhere.
*/
@Test
fun `given anchors have not been published, when dragging, then the divider does not move`() {
val expansion = expansion(withAnchors = false)
expansion.dragBy(100f)
assertEquals(250.dp, expansion.listWidth)
}
@Test
fun `given a drag within the anchors, then the divider follows it`() {
val expansion = expansion()
expansion.dragBy(-100f)
assertEquals(200.dp, expansion.listWidth)
}
@Test
fun `given a drag past the widest anchor, then the divider stops there`() {
val expansion = expansion()
expansion.dragBy(1000f)
assertEquals(450.dp, expansion.listWidth)
}
@Test
fun `given a drag past the narrowest anchor, then the divider stops there`() {
val expansion = expansion()
expansion.dragBy(-1000f)
assertEquals(50.dp, expansion.listWidth)
}
@Test
fun `given a drag that ended nearer another anchor, when settling, then that anchor is selected and moved to`() = runTest {
val expansion = expansion()
expansion.onDragStarted()
expansion.dragBy(300f)
withContext(TestFrameClock()) { expansion.settle() }
assertEquals(listOf(PaneAnchor.LIST_ONLY), selected)
assertEquals(450.dp, expansion.listWidth)
}
/**
* A state change arriving mid-gesture must not yank the divider out from under the user's finger.
*/
@Test
fun `given a drag in progress, when told to go to an anchor, then it is ignored`() = runTest {
val expansion = expansion()
expansion.onDragStarted()
withContext(TestFrameClock()) { expansion.goTo(PaneAnchor.DETAIL_ONLY) }
assertEquals(250.dp, expansion.listWidth)
}
@Test
fun `given no drag in progress, when told to go to an anchor, then the divider moves there`() = runTest {
val expansion = expansion()
withContext(TestFrameClock()) { expansion.goTo(PaneAnchor.DETAIL_ONLY) }
assertEquals(50.dp, expansion.listWidth)
}
/**
* How the divider is operated without a gesture: screen readers cannot drag, so the accessibility action
* steps through the anchors in declared order and wraps around.
*/
@Test
fun `when asked for the next anchor, then it cycles through them in declared order`() {
assertEquals(PaneAnchor.SPLIT, expansion(initialOffsetPx = 100f).nextAnchor())
assertEquals(PaneAnchor.LIST_ONLY, expansion(initialOffsetPx = 500f).nextAnchor())
assertEquals(PaneAnchor.DETAIL_ONLY, expansion(initialOffsetPx = 900f).nextAnchor())
}
@Test
fun `given anchors have not been published, when asked for the next anchor, then there is none`() {
assertNull(expansion(withAnchors = false).nextAnchor())
}
/**
* Selecting reports the anchor and nothing else the state change that comes back is what moves the
* divider, by the same path a drag takes.
*/
@Test
fun `when selecting an anchor, then it is reported without moving the divider`() {
val expansion = expansion()
expansion.selectAnchor(PaneAnchor.DETAIL_ONLY)
assertEquals(listOf(PaneAnchor.DETAIL_ONLY), selected)
assertEquals(250.dp, expansion.listWidth)
}
/** Runs animations to completion a frame at a time, without waiting on a real one. */
private class TestFrameClock : MonotonicFrameClock {
private var frameTimeNanos = 0L
override suspend fun <R> withFrameNanos(onFrame: (Long) -> R): R {
frameTimeNanos += 16_000_000
return onFrame(frameTimeNanos)
}
}
}
@@ -0,0 +1,38 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.ui.compose.split
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
/**
* Keys the split-pane tests navigate with. Serializable because the back stack is persisted through
* `kotlinx.serialization`, which resolves each entry's serializer from its concrete class.
*/
/** The list a stack is rooted at, a list pushed on top of it, and the root of a different stack. */
@Serializable
enum class TestListKey : ListNavKey {
ROOT,
PUSHED,
OTHER
}
/** Detail content root, so pushing one replaces the detail content already displayed. */
@Serializable
data class TestDetailKey(val id: Int) : DetailNavKey {
override val isContentRoot: Boolean get() = true
}
/** Not a content root, so pushing one stacks on top of the detail content already displayed. */
@Serializable
data class TestSubScreenKey(val id: Int, val label: String = "") : DetailNavKey {
override val isContentRoot: Boolean get() = false
}
/** Neither a list nor detail content: a screen that takes the whole window. */
@Serializable
data object TestFullScreenKey : NavKey
@@ -26,6 +26,5 @@ class CoreUiDependenciesRule(
override fun providePackageId(): String = "org.thoughtcrime.securesms"
override fun provideIsIncognitoKeyboardEnabled(): Boolean = isIncognitoKeyboardEnabled
override fun provideIsScreenSecurityEnabled(): Boolean = false
override fun provideForceSplitPane(): Boolean = false
}
}
+1
View File
@@ -0,0 +1 @@
/build
+31
View File
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
plugins {
id("signal-sample-app")
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinx.serialization)
}
android {
namespace = "org.signal.listdetail.demo"
defaultConfig {
applicationId = "org.signal.listdetail.demo"
}
}
dependencies {
implementation(project(":core:ui"))
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Signal">
<!-- Mirrors MainActivity: no screenSize/screenLayout here, so folding recreates the activity. -->
<activity
android:name=".MainActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden"
android:enableOnBackInvokedCallback="true"
android:exported="true"
android:label="@string/app_name"
android:resizeableActivity="true"
android:theme="@style/Theme.Signal">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,53 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
/** A row in a list, and the thing the detail pane displays. */
data class DemoItem(val id: Int, val title: String, val subtitle: String)
/**
* The contents of each list. All static: the demo is about navigation, not about data.
*/
object DemoData {
val inbox: List<DemoItem> = listOf(
DemoItem(1, "Nadia", "Sent a photo"),
DemoItem(2, "Weekend Plans", "Miguel: I can drive"),
DemoItem(3, "Priya", "Thanks!"),
DemoItem(4, "Book Club", "Ana: Chapter four tonight"),
DemoItem(5, "Sam", "Are you around later?"),
DemoItem(6, "Deniz", "Sent a voice message"),
DemoItem(7, "Roommates", "Jo: Trash goes out tomorrow"),
DemoItem(8, "Ines", "See you then")
)
val archive: List<DemoItem> = listOf(
DemoItem(101, "Old Group", "Archived last spring"),
DemoItem(102, "Yusuf", "Archived in March"),
DemoItem(103, "Delivery Updates", "Archived in January")
)
val contacts: List<DemoItem> = listOf(
DemoItem(201, "Ana", "+1 555 0100"),
DemoItem(202, "Deniz", "+1 555 0101"),
DemoItem(203, "Ines", "+1 555 0102"),
DemoItem(204, "Miguel", "+1 555 0103"),
DemoItem(205, "Nadia", "+1 555 0104"),
DemoItem(206, "Priya", "+1 555 0105"),
DemoItem(207, "Sam", "+1 555 0106"),
DemoItem(208, "Yusuf", "+1 555 0107")
)
private val byId: Map<Int, DemoItem> = (inbox + archive + contacts).associateBy { it.id }
fun itemsFor(route: DemoListRoute): List<DemoItem> = when (route) {
DemoListRoute.INBOX -> inbox
DemoListRoute.ARCHIVE -> archive
DemoListRoute.CONTACTS -> contacts
}
operator fun get(id: Int): DemoItem = byId.getValue(id)
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
import org.signal.core.ui.compose.split.ListDetailEvents
/**
* Everything the user can do in this demo.
*/
sealed interface DemoEvents {
/** Navigating to the archive screen, which could have special processing. */
data object ArchiveSelected : DemoEvents
/** Something that is navigation and nothing else, so the navigator can answer it unaided. */
data class ListDetailEvent(val event: ListDetailEvents) : DemoEvents
}
@@ -0,0 +1,58 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
import org.signal.core.ui.compose.split.DetailNavKey
import org.signal.core.ui.compose.split.ListNavKey
/**
* The lists this demo can display. Implementing [ListNavKey] is what puts them in the list pane.
*
* [INBOX] and [CONTACTS] are tabs, each with a stack of its own. [ARCHIVE] is not: it is pushed onto the
* inbox's stack, so opening it keeps whatever item was already open beside it.
*/
@Serializable
enum class DemoListRoute(val label: String) : ListNavKey {
INBOX("Inbox"),
ARCHIVE("Archive"),
CONTACTS("Contacts");
/** The tab this list is displayed under, which is the root of the stack it lives on. */
val tab: DemoListRoute
get() = if (this == ARCHIVE) INBOX else this
}
/**
* The detail content this demo can display above a list. Implementing [DetailNavKey] is what puts them in
* the detail pane.
*/
@Serializable
sealed interface DemoDetailRoute : DetailNavKey {
val itemId: Int
/** An item opened from a list. A content root, so opening another item replaces this one. */
@Serializable
data class Item(override val itemId: Int) : DemoDetailRoute {
override val isContentRoot: Boolean = true
}
/** Opened from an [Item]. Not a content root, so it stacks on top of the item instead of replacing it. */
@Serializable
data class Notes(override val itemId: Int) : DemoDetailRoute {
override val isContentRoot: Boolean = false
}
}
/**
* Settings, which are neither a list nor detail content: they take the whole window and navigate for
* themselves once open. A plain [NavKey], since nothing about the panes applies to them, and it stacks
* above whatever was open so that popping it puts the panes back the way they were.
*/
@Serializable
data object DemoSettingsRoute : NavKey
@@ -0,0 +1,262 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
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.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.split.ListDetailEvents
/**
* List-pane content. Every list is the same nav entry, so moving between the inbox, the archive and the
* contacts animates this pane alone and leaves the chrome around it untouched.
*
* @param selectedItemId the item open in the detail pane, highlighted so that a split-pane window shows
* which row the detail beside it belongs to.
*/
@Composable
fun ItemListPane(
route: DemoListRoute,
selectedItemId: Int?,
onEvent: (DemoEvents) -> Unit,
modifier: Modifier = Modifier
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
if (route == DemoListRoute.INBOX) {
item {
ItemRow(
title = DemoListRoute.ARCHIVE.label,
subtitle = "${DemoData.archive.size} conversations",
onClick = { onEvent(DemoEvents.ArchiveSelected) },
icon = Icons.Filled.Archive,
trailingIcon = Icons.AutoMirrored.Filled.KeyboardArrowRight
)
}
}
items(items = DemoData.itemsFor(route), key = { it.id }) { item ->
ItemRow(
title = item.title,
subtitle = item.subtitle,
onClick = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Push(DemoDetailRoute.Item(item.id)))) },
isSelected = item.id == selectedItemId
)
}
}
}
/** Detail-pane content for an item. */
@Composable
fun ItemDetailPane(
item: DemoItem,
onEvent: (DemoEvents) -> Unit,
modifier: Modifier = Modifier
) {
DetailScaffold(title = item.title, onBack = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Back)) }, modifier = modifier) {
Text(
text = item.subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Push(DemoDetailRoute.Notes(item.id)))) }) {
Text(text = "Open notes")
}
}
}
/** Detail-pane content stacked on top of [ItemDetailPane], rather than replacing it. */
@Composable
fun ItemNotesPane(
item: DemoItem,
onEvent: (DemoEvents) -> Unit,
modifier: Modifier = Modifier
) {
DetailScaffold(title = "Notes", onBack = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Back)) }, modifier = modifier) {
Text(
text = "Notes about ${item.title}. Back returns to the item, not to the list.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
}
/**
* Fills the detail pane before anything has been opened. Only ever seen in a split-pane window; a single
* pane displays the list instead.
*/
@Composable
fun EmptyDetailPane(modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier.fillMaxSize()
) {
Text(
text = "Select something from the list",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DetailScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Scaffold(
containerColor = Color.Transparent,
topBar = {
TopAppBar(
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
},
modifier = modifier
) { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp, Alignment.CenterVertically),
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 32.dp)
) {
content()
}
}
}
@Composable
private fun ItemRow(
title: String,
subtitle: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
trailingIcon: ImageVector? = null,
isSelected: Boolean = false
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp)
.clip(MaterialTheme.shapes.large)
.background(if (isSelected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = null)
} else {
Text(text = title.take(1), style = MaterialTheme.typography.titleMedium)
}
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (trailingIcon != null) {
Icon(
imageVector = trailingIcon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@DayNightPreviews
@Composable
private fun ItemListPanePreview() {
Previews.Preview {
ItemListPane(
route = DemoListRoute.INBOX,
selectedItemId = 2,
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun ItemDetailPanePreview() {
Previews.Preview {
ItemDetailPane(
item = DemoData.inbox.first(),
onEvent = {}
)
}
}
@@ -0,0 +1,145 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import kotlinx.serialization.Serializable
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.navigation.TransitionSpecs
/**
* Settings' own nav keys. The stack outside knows about settings as a single entry; these are the screens
* within it, and nothing outside needs to know they exist.
*/
@Serializable
private sealed interface SettingsRoute : NavKey {
@Serializable
data object Root : SettingsRoute
@Serializable
data class Section(val title: String) : SettingsRoute
}
private val SECTIONS = listOf("Notifications", "Privacy", "Storage")
/**
* A section that takes the whole window and navigates for itself, which is what a plain `entry` gets you:
* no list pane, no divider, no empty detail just this, over the panes it was pushed on top of.
*
* Its back stack is a [rememberNavBackStack], so the entry decorators hold it while the user is off in
* another tab and hand it back with settings still open on whatever screen they left it.
*
* @param onEvent reports back out to the demo, which is how settings gets popped off the stack outside
* once this display is at its own root.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onEvent: (DemoEvents) -> Unit, modifier: Modifier = Modifier) {
val backStack = rememberNavBackStack(SettingsRoute.Root)
val paneShift = TransitionSpecs.paneShift()
val paneShiftPop = TransitionSpecs.paneShift(pop = true)
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
transitionSpec = { paneShift },
popTransitionSpec = { paneShiftPop },
predictivePopTransitionSpec = { paneShiftPop },
modifier = modifier.fillMaxSize(),
entryProvider = entryProvider {
entry<SettingsRoute.Root> {
SettingsScaffold(title = "Settings", onBack = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Back)) }) {
SECTIONS.forEach { section ->
SettingsRow(title = section, onClick = { backStack.add(SettingsRoute.Section(section)) })
}
}
}
entry<SettingsRoute.Section> { route ->
SettingsScaffold(title = route.title, onBack = { backStack.removeLastOrNull() }) {
Text(
text = "Back here returns to settings, not to the list underneath.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsScaffold(
title: String,
onBack: () -> Unit,
content: @Composable () -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
content()
}
}
}
@Composable
private fun SettingsRow(title: String, onClick: () -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 24.dp, vertical = 20.dp)
) {
Text(text = title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -0,0 +1,55 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.compose.split.ListDetailNavigator
import org.signal.core.ui.compose.split.PaneAnchor
import org.signal.core.util.logging.Log
/**
* Owns the navigation. Every event here is answered by a one-liner into [ListDetailNavigator], which is
* the point: you keep your screens' rules, and it keeps the stacks, the pane anchor, and the saved state.
*/
class DemoViewModel(savedStateHandle: SavedStateHandle) : EventDrivenViewModel<DemoEvents>(TAG) {
companion object {
private val TAG = Log.tag(DemoViewModel::class)
}
val navigator = ListDetailNavigator<DemoListRoute, DemoDetailRoute>(
savedStateHandle = savedStateHandle,
scope = viewModelScope,
stackKeys = mapOf(
DemoListRoute.INBOX to "inbox_stack",
DemoListRoute.CONTACTS to "contacts_stack"
),
initialRoot = DemoListRoute.INBOX
)
/** The selected tab, which is the stack being displayed. */
val currentTab: StateFlow<DemoListRoute> = navigator.currentRoot
/** The list on top of that stack — the archive, whenever it has been pushed. */
val displayedList: StateFlow<DemoListRoute> = navigator.displayedList
/** The detail content above the displayed list, or null when the list is showing on its own. */
val detail: StateFlow<DemoDetailRoute?> = navigator.detail
/** Where the divider sits in a split-pane window. */
val paneAnchor: StateFlow<PaneAnchor> = navigator.paneAnchor
override suspend fun processEvent(event: DemoEvents) {
when (event) {
DemoEvents.ArchiveSelected -> navigator.processEvent(ListDetailEvents.GoToList(DemoListRoute.ARCHIVE, root = DemoListRoute.INBOX, push = true))
is DemoEvents.ListDetailEvent -> navigator.processEvent(event.event)
}
}
}
@@ -0,0 +1,261 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.listdetail.demo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import org.signal.core.ui.NavigationType
import org.signal.core.ui.compose.split.ListDetailEvents
import org.signal.core.ui.compose.split.ListDetailNavDisplay
import org.signal.core.ui.compose.split.ListPaneChrome
import org.signal.core.ui.compose.split.detailEntry
import org.signal.core.ui.compose.split.listEntry
import org.signal.core.ui.compose.split.rememberCurrentDecoratedNavEntries
import org.signal.core.ui.compose.split.rememberListDetailPaneLayout
import org.signal.core.ui.compose.split.rememberListDetailPaneMetrics
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.ui.rememberIsSplitPane
import org.signal.core.util.logging.AndroidLogger
import org.signal.core.util.logging.Log
/**
* A small list/detail app built on `org.signal.core.ui.compose.split`.
*
* [DemoScreen] is where it comes together, and every argument [ListDetailNavDisplay] takes is built in
* this file. The nav keys are in `DemoRoutes.kt`, the stacks behind them in `DemoViewModel.kt`, and the
* panes' content in `DemoScreens.kt`.
*/
class MainActivity : ComponentActivity() {
private val viewModel: DemoViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// So that the event each screen reports shows up in logcat, which is half the point of the pattern.
Log.initialize(AndroidLogger)
enableEdgeToEdge()
setContent {
SignalTheme(incognitoKeyboardEnabled = false) {
DemoScreen(viewModel)
}
}
}
}
@Composable
private fun DemoScreen(viewModel: DemoViewModel) {
val onEvent: (DemoEvents) -> Unit = viewModel::onEvent
val isSplitPane = LocalResources.current.rememberIsSplitPane()
val paneAnchor by viewModel.paneAnchor.collectAsStateWithLifecycle()
val displayedList by viewModel.displayedList.collectAsStateWithLifecycle()
val hasRail = NavigationType.rememberNavigationType() == NavigationType.RAIL
val entries = rememberDemoEntries(viewModel)
val listPaneChrome: ListPaneChrome = remember(displayedList, onEvent) { { content -> DemoListPaneChrome(displayedList, onEvent, content) } }
val emptyDetailContent: @Composable () -> Unit = remember { { EmptyDetailPane() } }
SignalTheme(incognitoKeyboardEnabled = false) {
Surface {
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(if (isSplitPane) SignalTheme.colors.colorSurface1 else MaterialTheme.colorScheme.surface)
.systemBarsPadding()
) {
ListDetailNavDisplay(
entries = entries,
isSplitPane = isSplitPane,
paneAnchor = paneAnchor,
onBack = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Back)) },
onExitDetail = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.ExitDetail)) },
layout = rememberListDetailPaneLayout(
paneAnchor = paneAnchor,
maxWidth = maxWidth,
onAnchorSelected = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.AnchorSelected(it))) },
collapsedListWidth = if (hasRail) RAIL_WIDTH else 0.dp
),
listPaneChrome = listPaneChrome,
emptyDetailContent = emptyDetailContent
)
}
}
}
}
/**
* The entry provider, and the decorated entries of the displayed tab's stack. Lists go through
* [listEntry], detail content through [detailEntry], and a screen that takes the whole window through a
* plain `entry`.
*/
@Composable
private fun rememberDemoEntries(viewModel: DemoViewModel): List<NavEntry<NavKey>> {
val entryProvider = remember(viewModel) {
entryProvider {
listEntry<DemoListRoute> { route ->
val detail by viewModel.detail.collectAsStateWithLifecycle()
ItemListPane(
route = route,
selectedItemId = detail?.itemId,
onEvent = viewModel::onEvent
)
}
detailEntry<DemoDetailRoute.Item> { route ->
ItemDetailPane(
item = DemoData[route.itemId],
onEvent = viewModel::onEvent
)
}
detailEntry<DemoDetailRoute.Notes> { route ->
ItemNotesPane(
item = DemoData[route.itemId],
onEvent = viewModel::onEvent
)
}
// A plain entry: neither pane claims it, so it is displayed over both of them.
entry<DemoSettingsRoute> {
SettingsScreen(onEvent = viewModel::onEvent)
}
}
}
return rememberCurrentDecoratedNavEntries(viewModel.navigator, entryProvider)
}
/**
* List pane chrome that won't animate when the list changes (other than your own animations ofc.) for stuff like nav rails, megaphones, etc.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DemoListPaneChrome(
displayedList: DemoListRoute,
onEvent: (DemoEvents) -> Unit,
content: @Composable () -> Unit
) {
val navigationType = NavigationType.rememberNavigationType()
val metrics = rememberListDetailPaneMetrics()
Row(modifier = Modifier.fillMaxSize()) {
if (navigationType == NavigationType.RAIL) {
NavigationRail(containerColor = Color.Transparent) {
TABS.forEach { (route, icon) ->
NavigationRailItem(
selected = displayedList.tab == route,
onClick = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.GoToList(route))) },
icon = { TabIcon(icon, route) },
label = { Text(text = route.label) }
)
}
}
}
Column(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface, metrics.shape)
.clip(metrics.shape)
) {
TopAppBar(
title = { Text(text = displayedList.label) },
navigationIcon = {
if (displayedList == DemoListRoute.ARCHIVE) {
IconButton(onClick = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Back)) }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
},
actions = {
IconButton(onClick = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Push(DemoSettingsRoute))) }) {
Icon(imageVector = Icons.Filled.Settings, contentDescription = "Settings")
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
Box(modifier = Modifier.weight(1f)) {
content()
}
if (navigationType == NavigationType.BAR) {
NavigationBar(
containerColor = Color.Transparent,
modifier = Modifier.clip(metrics.navigationBarShape)
) {
TABS.forEach { (route, icon) ->
NavigationBarItem(
selected = displayedList.tab == route,
onClick = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.GoToList(route))) },
icon = { TabIcon(icon, route) },
label = { Text(text = route.label) }
)
}
}
}
}
}
}
@Composable
private fun TabIcon(icon: ImageVector, route: DemoListRoute) {
Icon(imageVector = icon, contentDescription = route.label)
}
/** The tabs the chrome offers. The archive is reached from inside the inbox, so it is not one of them. */
private val TABS = listOf(
DemoListRoute.INBOX to Icons.Filled.Inbox,
DemoListRoute.CONTACTS to Icons.Filled.Person
)
/** What is left of the list pane once the detail fills the window: the navigation rail, when there is one. */
private val RAIL_WIDTH = 80.dp
@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,31 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,8 @@
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<string name="app_name">List Detail Demo</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<style name="Theme.Signal" parent="Theme.AppCompat.DayNight.NoActionBar" />
</resources>
@@ -99,7 +99,6 @@ class RegistrationApplication : Application() {
override fun providePackageId(): String = BuildConfig.APPLICATION_ID
override fun provideIsIncognitoKeyboardEnabled(): Boolean = false
override fun provideIsScreenSecurityEnabled(): Boolean = false
override fun provideForceSplitPane(): Boolean = false
}
)
}
+1
View File
@@ -147,6 +147,7 @@ include(":demo:debuglogs-viewer")
include(":demo:registration")
include(":demo:camera")
include(":demo:apng")
include(":demo:list-detail")
// Testing/Lint modules
include(":lintchecks")