mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-21 01:05:11 +01:00
Migrate main navigation to Navigation 3.
This commit is contained in:
+48
-46
@@ -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(
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
-17
@@ -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 = {
|
||||
|
||||
-2
@@ -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 = ""
|
||||
)
|
||||
|
||||
-12
@@ -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
|
||||
|
||||
+1
-2
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+7
-7
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
-3
@@ -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
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-17
@@ -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
|
||||
|
||||
+1
-3
@@ -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
-37
@@ -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> {
|
||||
|
||||
+87
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+107
@@ -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()
|
||||
}
|
||||
}
|
||||
+210
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
-1
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user