From 2627ff1b9554cc895d1d69b11f6fbcf2102ce764 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 28 Aug 2026 15:01:22 -0300 Subject: [PATCH] Migrate main navigation to Navigation 3. --- .../main/MainNavigationLaunchTest.kt | 94 +-- .../thoughtcrime/securesms/MainActivity.kt | 592 +++++++----------- .../thoughtcrime/securesms/MainNavigator.java | 4 +- .../securesms/calls/CallsBackStack.kt | 64 -- .../securesms/calls/CallsNavigation.kt | 36 +- .../links/details/CallLinkDetailsScreen.kt | 4 +- .../securesms/calls/log/CallLogFragment.kt | 10 +- .../securesms/calls/new/NewCallActivity.kt | 5 +- .../securesms/calls/new/NewCallViewModel.kt | 1 - .../securesms/chats/ChatsBackStack.kt | 64 -- .../securesms/chats/ChatsNavigation.kt | 54 +- .../app/internal/InternalSettingsFragment.kt | 17 - .../app/internal/InternalSettingsState.kt | 2 - .../app/internal/InternalSettingsViewModel.kt | 12 - .../ConversationSettingsFragment.kt | 3 +- .../ConversationSettingsNavigator.kt | 4 +- .../conversation/NewConversationActivity.kt | 5 +- .../conversation/NewConversationViewModel.kt | 1 - .../conversation/v2/ConversationActivity.kt | 8 +- .../conversation/v2/ConversationFragment.kt | 14 +- .../ConversationListArchiveFragment.java | 4 +- .../ConversationListFragment.java | 8 +- .../ConversationListFragmentExtensions.kt | 8 +- .../CoreUiDependenciesProvider.kt | 5 - .../ui/addmembers/AddMembersActivity.kt | 2 - .../ui/addmembers/AddMembersViewModel.kt | 2 - .../ui/addtogroup/AddToGroupsActivity.kt | 3 - .../ui/addtogroup/AddToGroupsViewModel.kt | 2 - .../ui/creategroup/CreateGroupActivity.kt | 2 - .../ui/creategroup/CreateGroupViewModel.kt | 2 - .../securesms/keyvalue/InternalValues.kt | 12 - .../securesms/main/MainBottomChrome.kt | 8 +- .../securesms/main/MainContentLayoutData.kt | 118 ---- .../securesms/main/MainDetailBackStack.kt | 56 +- ...onDetailLocation.kt => MainDetailRoute.kt} | 27 +- .../main/MainFloatingActionButtons.kt | 56 +- .../securesms/main/MainListRoute.kt | 50 ++ .../securesms/main/MainNavigation.kt | 422 ++----------- .../securesms/main/MainNavigationBar.kt | 334 ++++++++++ .../securesms/main/MainNavigationBarState.kt | 30 + .../securesms/main/MainNavigationRouter.kt | 15 +- .../securesms/main/MainNavigationViewModel.kt | 373 ++++------- .../securesms/main/MainToolbar.kt | 21 +- .../securesms/main/MainToolbarViewModel.kt | 12 +- .../recipients/ui/RecipientPickerScaffold.kt | 4 +- .../securesms/stories/StoriesBackStack.kt | 51 -- .../securesms/stories/StoriesNavigation.kt | 45 +- .../stories/landing/StoriesLandingFragment.kt | 41 +- .../securesms/stories/my/MyStoriesFragment.kt | 14 - .../securesms/window/AppScaffold.kt | 42 +- .../securesms/window/AppScaffoldNavigator.kt | 6 +- .../MainDetailBackStackPersistenceTest.kt | 87 +++ .../main/MainNavigationViewModelTest.kt | 295 +++++++++ core/ui/build.gradle.kts | 6 + .../org/signal/core/ui/CoreUiDependencies.kt | 4 - .../java/org/signal/core/ui/NavigationType.kt | 31 + .../core/ui/SavedStateHandleExtensions.kt | 41 ++ .../core/ui/WindowSizeClassExtensions.kt | 17 +- .../ui/compose/split/ListDetailBackStack.kt | 112 ++++ .../core/ui/compose/split/ListDetailEvents.kt | 36 ++ .../ui/compose/split/ListDetailNavDisplay.kt | 120 ++++ .../ui/compose/split/ListDetailNavigator.kt | 184 ++++++ .../ui/compose/split/ListDetailPaneLayout.kt | 116 ++++ .../ui/compose/split/ListDetailPaneMetrics.kt | 106 ++++ .../core/ui/compose/split/ListDetailScene.kt | 304 +++++++++ .../core/ui/compose/split/PaneAnchor.kt | 48 ++ .../ui/compose/split/PaneAnchorController.kt | 60 ++ .../core/ui/compose/split/PaneEntries.kt | 82 +++ .../ui/compose/split/PaneExpansionState.kt | 130 ++++ .../core/ui/navigation/TransitionSpecs.kt | 61 ++ core/ui/src/main/res/values/strings.xml | 6 + .../org/signal/core/ui/NavigationTypeTest.kt | 50 ++ .../ListDetailBackStackPersistenceTest.kt | 107 ++++ .../compose/split/ListDetailBackStackTest.kt | 261 ++++++++ .../compose/split/ListDetailNavigatorTest.kt | 235 +++++++ .../split/ListDetailSceneStrategyTest.kt | 210 +++++++ .../compose/split/PaneAnchorControllerTest.kt | 120 ++++ .../compose/split/PaneExpansionStateTest.kt | 153 +++++ .../core/ui/compose/split/TestNavKeys.kt | 38 ++ .../signal/core/ui/CoreUiDependenciesRule.kt | 1 - demo/list-detail/.gitignore | 1 + demo/list-detail/build.gradle.kts | 31 + demo/list-detail/src/main/AndroidManifest.xml | 33 + .../org/signal/listdetail/demo/DemoData.kt | 53 ++ .../org/signal/listdetail/demo/DemoEvents.kt | 20 + .../org/signal/listdetail/demo/DemoRoutes.kt | 58 ++ .../org/signal/listdetail/demo/DemoScreens.kt | 262 ++++++++ .../signal/listdetail/demo/DemoSettings.kt | 145 +++++ .../signal/listdetail/demo/DemoViewModel.kt | 55 ++ .../signal/listdetail/demo/MainActivity.kt | 261 ++++++++ .../res/drawable/ic_launcher_background.xml | 171 +++++ .../res/drawable/ic_launcher_foreground.xml | 31 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 7 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 7 + .../src/main/res/values/strings.xml | 8 + .../src/main/res/values/themes.xml | 10 + .../sample/RegistrationApplication.kt | 1 - settings.gradle.kts | 1 + 98 files changed, 5233 insertions(+), 1712 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainContentLayoutData.kt rename app/src/main/java/org/thoughtcrime/securesms/main/{MainNavigationDetailLocation.kt => MainDetailRoute.kt} (80%) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainListRoute.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBar.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBarState.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/main/MainDetailBackStackPersistenceTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/NavigationType.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/SavedStateHandleExtensions.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailBackStack.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailEvents.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavDisplay.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavigator.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneLayout.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneMetrics.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailScene.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchor.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchorController.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/PaneEntries.kt create mode 100644 core/ui/src/main/java/org/signal/core/ui/compose/split/PaneExpansionState.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/NavigationTypeTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackPersistenceTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailNavigatorTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailSceneStrategyTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/PaneAnchorControllerTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/PaneExpansionStateTest.kt create mode 100644 core/ui/src/test/java/org/signal/core/ui/compose/split/TestNavKeys.kt create mode 100644 demo/list-detail/.gitignore create mode 100644 demo/list-detail/build.gradle.kts create mode 100644 demo/list-detail/src/main/AndroidManifest.xml create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoData.kt create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoEvents.kt create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoRoutes.kt create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoScreens.kt create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoSettings.kt create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoViewModel.kt create mode 100644 demo/list-detail/src/main/java/org/signal/listdetail/demo/MainActivity.kt create mode 100644 demo/list-detail/src/main/res/drawable/ic_launcher_background.xml create mode 100644 demo/list-detail/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 demo/list-detail/src/main/res/values/strings.xml create mode 100644 demo/list-detail/src/main/res/values/themes.xml diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt index 4c848a8192..e5f4718bed 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/main/MainNavigationLaunchTest.kt @@ -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() } 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 = 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 = 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") { diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 27696cf5e3..d2a240666c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -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( - 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( - 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( - 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 { - 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() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java index 293ed38fd6..0720af294d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt deleted file mode 100644 index 9097415080..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/CallsBackStack.kt +++ /dev/null @@ -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, ArrayList> = Saver( - save = { ArrayList(it) }, - restore = { mutableStateListOf(*it.toTypedArray()) } - ) - } - - override val entries: SnapshotStateList = 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) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt index 42cff81c30..7e02a5c476 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/CallsNavigation.kt @@ -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.callsNavEntries(isSplitPane: Boolean) { - entry { - NoCallSelectedEntry() - } - - entry( +/** + * Registers the routes utilized for the main screen calls tab. + */ +fun EntryProviderScope.registerCallsTabDetailRoutes(isSplitPane: Boolean) { + detailEntry( metadata = if (isSplitPane) TransitionSpecs.None.metadata else emptyMap() ) { route -> CallLinkDetailsEntry(route) } - entry { route -> + detailEntry { 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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt index 36de10dd8c..2d94b0555b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index fa2054f838..c9a9110822 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt index e9e7406382..fb7d28ecac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt index 759b7f1863..0c22024849 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt deleted file mode 100644 index 9f607f6b26..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsBackStack.kt +++ /dev/null @@ -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, ArrayList> = Saver( - save = { ArrayList(it) }, - restore = { mutableStateListOf(*it.toTypedArray()) } - ) - } - - override val entries: SnapshotStateList = 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) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt index b295bca836..f2a5fb86f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/chats/ChatsNavigation.kt @@ -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.chatsNavEntries( +/** + * Registers the routes available in the chats tab of the main screen. + */ +fun EntryProviderScope.registerChatsTabDetailRoutes( transitionState: ConversationTransitionState ) { - entry { - NoConvoSelectedEntry() - } - - entry( - // disable slide animation - it's unnecessary in split pane mode and is handled by ConversationLoadingMask for single pane mode. - metadata = TransitionSpecs.None.metadata + detailEntry( + // 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 { route -> + detailEntry { route -> MessageDetailsEntry(route) } - entry( + detailEntry( 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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 7883f5be70..58b5d3cea0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -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 = { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 25c84dd8ba..6c455f5d03 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -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 = "" ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 3a33a7e4fc..0d436fdae4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -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 { val groups = mutableListOf() var divider: DividerPreference? = null diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 070493b65c..60e9049310 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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())) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt index 488078feb3..19e510dee1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsNavigator.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt index c75f02bfcd..28d0459bfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt index 3ba15ec769..fc40d4fc41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt index 6b5407b7ee..d4e92f9bfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 91c07ec7ba..ef411da366 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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) { @@ -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)) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index cba0e2b593..3fe801a44e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -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); } }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index fbcefbc92f..c35daf6a96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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); } }) ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt index 04169c81fa..bce8c2123d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt @@ -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 + detailLocation: Flow ) { 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CoreUiDependenciesProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CoreUiDependenciesProvider.kt index a25a0fbeb0..0a171491c5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/CoreUiDependenciesProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/CoreUiDependenciesProvider.kt @@ -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 - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt index c166b38e8d..ed4eb396e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.kt index 8a1f71d4f1..0dc723fb39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersViewModel.kt @@ -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 = emptySet(), val selectionLimits: SelectionLimits, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt index 7a1af4b1f4..646558c071 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsActivity.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt index 26232ef591..6268841dfa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModel.kt @@ -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 = emptySet(), val selectionLimits: SelectionLimits? = null, diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt index 9ceec437c4..7e2ff4a5a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt index d5ee2d4123..b0c05bdc55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt @@ -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 = emptyList(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt index 032bb49e32..65f4479e1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt @@ -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 = 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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt index 19a0f4cb1d..0861b481a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainContentLayoutData.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainContentLayoutData.kt deleted file mode 100644 index a6f28fd7f5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainContentLayoutData.kt +++ /dev/null @@ -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 - } - ) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt index c2b8c96796..f923cc0129 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailBackStack.kt @@ -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 +/** + * The list currently being displayed. + */ +val ListDetailBackStack.listLocation: MainListRoute + get() = listLocation() - 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() - 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 } } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailRoute.kt similarity index 80% rename from app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt rename to app/src/main/java/org/thoughtcrime/securesms/main/MainDetailRoute.kt index 6cdae0fde1..2f4d70c4f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainDetailRoute.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt index ea990a3a94..83ab345be3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt @@ -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 } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainListRoute.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainListRoute.kt new file mode 100644 index 0000000000..1476c76ac2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainListRoute.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt index 84034df524..47df81f177 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt @@ -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> { + 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 { + 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.registerMainScreenRoutes() { + listEntry { 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()) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBar.kt new file mode 100644 index 0000000000..eb38610d0d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBar.kt @@ -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 } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBarState.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBarState.kt new file mode 100644 index 0000000000..da9c16083d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationBarState.kt @@ -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 = 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt index 142be21938..d278c35d70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index 0079c55eea..48a9415a39 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -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 create(modelClass: Class, extras: CreationExtras): T { val savedStateHandle = extras.createSavedStateHandle() @@ -83,45 +71,39 @@ class MainNavigationViewModel( private val megaphoneRepository = AppDependencies.megaphoneRepository - private var navigator: AppScaffoldNavigator? = 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 - 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( + 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 - get() = callsBackStack.entries + /** The currently selected tab. */ + val currentTab: StateFlow = navigator.currentRoot - private val storiesBackStack: StoriesBackStack = StoriesBackStack(savedStateHandle) - val storiesBackStackEntries: SnapshotStateList - get() = storiesBackStack.entries + /** How a split-pane window currently divides the list and detail panes. */ + val paneAnchor: StateFlow = 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 = internalIsFullScreenPane + /** Whether one pane currently occupies the whole window. Only meaningful in split-pane layouts. */ + val isFullScreenPane: StateFlow = navigator.isFullScreenPane val observableActiveRecipientId: Observable> = - 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> = - 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 = internalMainNavigationState + private val internalMainNavigationBarState = MutableStateFlow(MainNavigationBarState(currentListLocation = initialListLocation)) + val mainNavigationBarState: StateFlow = combine(internalMainNavigationBarState, navigator.displayedList) { state, listLocation -> + state.copy(currentListLocation = listLocation) + }.stateIn(viewModelScope, SharingStarted.Eagerly, MainNavigationBarState(currentListLocation = initialListLocation)) - @OptIn(ExperimentalCoroutinesApi::class) - val detailLocation: StateFlow = 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 = 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 = navigator.hasDetail /** * This is Rx because these are still accessed from Java. */ - private val internalTabClickEvents: MutableSharedFlow = MutableSharedFlow() - val tabClickEventsObservable: Observable = internalTabClickEvents.asObservable() - - private var earlyNavigationListLocationRequested: MainNavigationListLocation? = null - - private val internalPaneFocusRequests = MutableSharedFlow() - val paneFocusRequests: SharedFlow = 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 = MutableSharedFlow() + val tabClickEventsObservable: Observable = 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): AppScaffoldNavigator { - 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> { @@ -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 performStoreUpdate(flow: Flow, fn: (T, MainNavigationState) -> MainNavigationState) { + private fun performStoreUpdate(flow: Flow, 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(delegate: ThreePaneScaffoldNavigator) : AppScaffoldNavigator(delegate) { - override suspend fun navigateBack(backNavigationBehavior: BackNavigationBehavior): Boolean { - val result = super.navigateBack(backNavigationBehavior) - if (result) { - lockPaneToSecondary = true - } - return result - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index 313df449e9..773532e291 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt index c66a759b21..c8227e23f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt index 513a21aa08..cda7ff1fa9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPickerScaffold.kt @@ -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(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt deleted file mode 100644 index d9eae7264b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesBackStack.kt +++ /dev/null @@ -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, ArrayList> = Saver( - save = { ArrayList(it) }, - restore = { mutableStateListOf(*it.toTypedArray()) } - ) - } - - override val entries: SnapshotStateList = 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) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt index 2757f1203d..a8adc17b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoriesNavigation.kt @@ -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.storiesNavEntries() { - entry { - NoStorySelectedEntry() - } - - entry( +fun EntryProviderScope.registerStoriesTabDetailRoutes() { + detailEntry( metadata = TransitionSpecs.None.metadata ) { StoryArchiveEntry() } - entry( + detailEntry( metadata = TransitionSpecs.None.metadata ) { MyStoriesEntry() } - entry( + detailEntry( 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index 56ac7943f9..af4ece1fdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -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 - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index ba7d278d45..87a9833971 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -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(R.id.empty_notice) lifecycleDisposable.bindTo(viewLifecycleOwner) viewModel.state.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt index 47ac0e9e87..c773f457c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -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 = {} ) }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt index 0c75554dd5..76aa1a272b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffoldNavigator.kt @@ -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 @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 { diff --git a/app/src/test/java/org/thoughtcrime/securesms/main/MainDetailBackStackPersistenceTest.kt b/app/src/test/java/org/thoughtcrime/securesms/main/MainDetailBackStackPersistenceTest.kt new file mode 100644 index 0000000000..2c3f2ea0ff --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/main/MainDetailBackStackPersistenceTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt new file mode 100644 index 0000000000..57668f7355 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/main/MainNavigationViewModelTest.kt @@ -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) + } +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 7a59f981ae..f33d105ca2 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -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) } diff --git a/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt b/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt index 93b5479ee2..38e3b5273c 100644 --- a/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt +++ b/core/ui/src/main/java/org/signal/core/ui/CoreUiDependencies.kt @@ -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 } } diff --git a/core/ui/src/main/java/org/signal/core/ui/NavigationType.kt b/core/ui/src/main/java/org/signal/core/ui/NavigationType.kt new file mode 100644 index 0000000000..7d108b0531 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/NavigationType.kt @@ -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 + } diff --git a/core/ui/src/main/java/org/signal/core/ui/SavedStateHandleExtensions.kt b/core/ui/src/main/java/org/signal/core/ui/SavedStateHandleExtensions.kt new file mode 100644 index 0000000000..09d3561dab --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/SavedStateHandleExtensions.kt @@ -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> { + 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 { + return BackStackHolder(this, key, root).backStack +} + +private class BackStackHolder(savedStateHandle: SavedStateHandle, key: String, root: NavKey) { + val backStack: NavBackStack by savedStateHandle.backStack(key, root) +} diff --git a/core/ui/src/main/java/org/signal/core/ui/WindowSizeClassExtensions.kt b/core/ui/src/main/java/org/signal/core/ui/WindowSizeClassExtensions.kt index 0cfc12386c..4b001c5f39 100644 --- a/core/ui/src/main/java/org/signal/core/ui/WindowSizeClassExtensions.kt +++ b/core/ui/src/main/java/org/signal/core/ui/WindowSizeClassExtensions.kt @@ -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 diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailBackStack.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailBackStack.kt new file mode 100644 index 0000000000..41f019fcbe --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailBackStack.kt @@ -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 + +/** + * 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 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 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) + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailEvents.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailEvents.kt new file mode 100644 index 0000000000..f7c21e8a6c --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailEvents.kt @@ -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 +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavDisplay.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavDisplay.kt new file mode 100644 index 0000000000..fd4c0a2e7c --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavDisplay.kt @@ -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 +): List> { + val currentRoot by navigator.currentRoot.collectAsStateWithLifecycle() + + var currentEntries: List>? = 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>, + 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 + ) + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavigator.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavigator.kt new file mode 100644 index 0000000000..d07208b33f --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailNavigator.kt @@ -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( + savedStateHandle: SavedStateHandle, + scope: CoroutineScope, + stackKeys: Map, + 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 = 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 = internalCurrentRoot.asStateFlow() + + /** How a split-pane window currently divides the two panes. Persisted. */ + val paneAnchor: StateFlow = 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 = paneAnchorController.isFullScreenPane + .stateIn(scope, SharingStarted.Eagerly, false) + + /** The list displayed by the current root's stack. */ + @OptIn(ExperimentalCoroutinesApi::class) + val displayedList: StateFlow = 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 = 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 = 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 snapshotsOf(root: L, read: ListDetailBackStack.() -> T): Flow { + 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 +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneLayout.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneLayout.kt new file mode 100644 index 0000000000..a8d228b704 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneLayout.kt @@ -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 { 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 { 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> { {} } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneMetrics.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneMetrics.kt new file mode 100644 index 0000000000..07a42d1249 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailPaneMetrics.kt @@ -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 + ) + } + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailScene.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailScene.kt new file mode 100644 index 0000000000..a510f5580e --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/ListDetailScene.kt @@ -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>, + private val listEntry: NavEntry, + private val detailEntry: NavEntry? +) : Scene { + + override val entries: List> = 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>, + private val entry: NavEntry, + private val isList: Boolean +) : Scene { + + override val entries: List> = 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 { + + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + 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" diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchor.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchor.kt new file mode 100644 index 0000000000..47567e05d6 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchor.kt @@ -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 { + return with(density) { + mapOf( + PaneAnchor.DETAIL_ONLY to detailOnly.toPx(), + PaneAnchor.SPLIT to split.toPx(), + PaneAnchor.LIST_ONLY to listOnly.toPx() + ) + } + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchorController.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchorController.kt new file mode 100644 index 0000000000..a46a54ef64 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneAnchorController.kt @@ -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 = savedStateHandle.getStateFlow(KEY, PaneAnchor.SPLIT) + + /** + * Whether one pane currently occupies the whole window. + */ + val isFullScreenPane: Flow = 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 + } +} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneEntries.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneEntries.kt new file mode 100644 index 0000000000..de92a12afc --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneEntries.kt @@ -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 + +/** + * Marks an entry as detail-pane content, the counterpart to [ListPane]. + */ +internal object DetailPane : NavMetadataKey + +/** + * 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 EntryProviderScope.listEntry( + metadata: Map = emptyMap(), + noinline content: @Composable (K) -> Unit +) { + entry(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 EntryProviderScope.detailEntry( + metadata: Map = emptyMap(), + noinline content: @Composable (K) -> Unit +) { + entry(metadata = metadata + detailPaneMetadata(), content = content) +} + +/** + * Metadata marking an entry as list-pane content, applied by [listEntry]. + */ +@PublishedApi +internal fun listPaneMetadata(): Map = metadata { put(ListPane, true) } + +/** + * Metadata marking an entry as detail-pane content, applied by [detailEntry]. + */ +@PublishedApi +internal fun detailPaneMetadata(): Map = 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 diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneExpansionState.kt b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneExpansionState.kt new file mode 100644 index 0000000000..c235922b01 --- /dev/null +++ b/core/ui/src/main/java/org/signal/core/ui/compose/split/PaneExpansionState.kt @@ -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 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) { + 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 diff --git a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt index 0c60d97798..19e6f67b74 100644 --- a/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/navigation/TransitionSpecs.kt @@ -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(durationMillis = PANE_SHIFT_DURATION, easing = PANE_SHIFT_EASING) + val fadeSpec = tween(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 get() = metadata { + put(NavDisplay.TransitionKey) { + Transition.NONE + } + } + /** * No enter/exit animation. */ diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 3d337e5643..53894f5cd4 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -18,6 +18,12 @@ Permission required + + + Resize panes + + Move divider + %1$s/%2$s diff --git a/core/ui/src/test/java/org/signal/core/ui/NavigationTypeTest.kt b/core/ui/src/test/java/org/signal/core/ui/NavigationTypeTest.kt new file mode 100644 index 0000000000..faecdfbb70 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/NavigationTypeTest.kt @@ -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) + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackPersistenceTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackPersistenceTest.kt new file mode 100644 index 0000000000..af77b749f6 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackPersistenceTest.kt @@ -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()) + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackTest.kt new file mode 100644 index 0000000000..029d097125 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailBackStackTest.kt @@ -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()) + 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()) + 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()) + 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()) + 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()) + assertFalse(backStack.hasDetail) + + backStack.pop() + + assertEquals(TestListKey.ROOT, backStack.listLocation()) + } + + @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()) + 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()) + } + + @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()) + 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() + } + + private object OtherListKey : ListNavKey +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailNavigatorTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailNavigatorTest.kt new file mode 100644 index 0000000000..c329e3c880 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailNavigatorTest.kt @@ -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 { + 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() + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailSceneStrategyTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailSceneStrategyTest.kt new file mode 100644 index 0000000000..34050afe94 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/ListDetailSceneStrategyTest.kt @@ -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>): Scene = scene(entries, isSplitPane = true) + + private fun singlePane(entries: List>): Scene = scene(entries, isSplitPane = false) + + private fun scene(entries: List>, isSplitPane: Boolean): Scene { + return with(ListDetailSceneStrategy(isSplitPane)) { + SceneStrategyScope().calculateScene(entries) + }!! + } + + /** What `NavDisplay` uses to decide whether to register a back handler at all. */ + private val Scene.isBackEnabled: Boolean + get() = previousEntries.isNotEmpty() + + /** What `NavDisplay` pops when its back handler completes. */ + private fun Scene.popsOnBack(entries: List>): Int = entries.size - previousEntries.size + + private fun listEntry(location: ListNavKey): NavEntry { + return NavEntry(key = location, metadata = listPaneMetadata()) {} + } + + private fun detailEntry(location: DetailNavKey): NavEntry { + return NavEntry(key = location, metadata = detailPaneMetadata()) {} + } + + /** An entry registered with a plain `entry`, claiming neither pane. */ + private fun plainEntry(location: NavKey): NavEntry { + return NavEntry(key = location) {} + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/PaneAnchorControllerTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/PaneAnchorControllerTest.kt new file mode 100644 index 0000000000..2ed4ad8d74 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/PaneAnchorControllerTest.kt @@ -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) + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/PaneExpansionStateTest.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/PaneExpansionStateTest.kt new file mode 100644 index 0000000000..16f9163522 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/PaneExpansionStateTest.kt @@ -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() + + 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 withFrameNanos(onFrame: (Long) -> R): R { + frameTimeNanos += 16_000_000 + return onFrame(frameTimeNanos) + } + } +} diff --git a/core/ui/src/test/java/org/signal/core/ui/compose/split/TestNavKeys.kt b/core/ui/src/test/java/org/signal/core/ui/compose/split/TestNavKeys.kt new file mode 100644 index 0000000000..7f86df0208 --- /dev/null +++ b/core/ui/src/test/java/org/signal/core/ui/compose/split/TestNavKeys.kt @@ -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 diff --git a/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt b/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt index 777d190130..ddae7f75be 100644 --- a/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt +++ b/core/ui/src/testFixtures/java/org/signal/core/ui/CoreUiDependenciesRule.kt @@ -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 } } diff --git a/demo/list-detail/.gitignore b/demo/list-detail/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/demo/list-detail/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/demo/list-detail/build.gradle.kts b/demo/list-detail/build.gradle.kts new file mode 100644 index 0000000000..1bf91c1961 --- /dev/null +++ b/demo/list-detail/build.gradle.kts @@ -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) +} diff --git a/demo/list-detail/src/main/AndroidManifest.xml b/demo/list-detail/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..089a6cc085 --- /dev/null +++ b/demo/list-detail/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoData.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoData.kt new file mode 100644 index 0000000000..ea52306124 --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoData.kt @@ -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 = 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 = listOf( + DemoItem(101, "Old Group", "Archived last spring"), + DemoItem(102, "Yusuf", "Archived in March"), + DemoItem(103, "Delivery Updates", "Archived in January") + ) + + val contacts: List = 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 = (inbox + archive + contacts).associateBy { it.id } + + fun itemsFor(route: DemoListRoute): List = when (route) { + DemoListRoute.INBOX -> inbox + DemoListRoute.ARCHIVE -> archive + DemoListRoute.CONTACTS -> contacts + } + + operator fun get(id: Int): DemoItem = byId.getValue(id) +} diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoEvents.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoEvents.kt new file mode 100644 index 0000000000..02ee2708aa --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoEvents.kt @@ -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 +} diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoRoutes.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoRoutes.kt new file mode 100644 index 0000000000..43888f197e --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoRoutes.kt @@ -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 diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoScreens.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoScreens.kt new file mode 100644 index 0000000000..16fc7c2054 --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoScreens.kt @@ -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 = {} + ) + } +} diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoSettings.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoSettings.kt new file mode 100644 index 0000000000..7d478c1444 --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoSettings.kt @@ -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 { + SettingsScaffold(title = "Settings", onBack = { onEvent(DemoEvents.ListDetailEvent(ListDetailEvents.Back)) }) { + SECTIONS.forEach { section -> + SettingsRow(title = section, onClick = { backStack.add(SettingsRoute.Section(section)) }) + } + } + } + + entry { 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 + ) + } +} diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoViewModel.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoViewModel.kt new file mode 100644 index 0000000000..f34aa22fc5 --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/DemoViewModel.kt @@ -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(TAG) { + + companion object { + private val TAG = Log.tag(DemoViewModel::class) + } + + val navigator = ListDetailNavigator( + 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 = navigator.currentRoot + + /** The list on top of that stack — the archive, whenever it has been pushed. */ + val displayedList: StateFlow = navigator.displayedList + + /** The detail content above the displayed list, or null when the list is showing on its own. */ + val detail: StateFlow = navigator.detail + + /** Where the divider sits in a split-pane window. */ + val paneAnchor: StateFlow = 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) + } + } +} diff --git a/demo/list-detail/src/main/java/org/signal/listdetail/demo/MainActivity.kt b/demo/list-detail/src/main/java/org/signal/listdetail/demo/MainActivity.kt new file mode 100644 index 0000000000..e83689e9f8 --- /dev/null +++ b/demo/list-detail/src/main/java/org/signal/listdetail/demo/MainActivity.kt @@ -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> { + val entryProvider = remember(viewModel) { + entryProvider { + listEntry { route -> + val detail by viewModel.detail.collectAsStateWithLifecycle() + + ItemListPane( + route = route, + selectedItemId = detail?.itemId, + onEvent = viewModel::onEvent + ) + } + + detailEntry { route -> + ItemDetailPane( + item = DemoData[route.itemId], + onEvent = viewModel::onEvent + ) + } + + detailEntry { 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 { + 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 diff --git a/demo/list-detail/src/main/res/drawable/ic_launcher_background.xml b/demo/list-detail/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..c71b77f4d2 --- /dev/null +++ b/demo/list-detail/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/list-detail/src/main/res/drawable/ic_launcher_foreground.xml b/demo/list-detail/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..18c0565852 --- /dev/null +++ b/demo/list-detail/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..7efa3313d2 --- /dev/null +++ b/demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..7efa3313d2 --- /dev/null +++ b/demo/list-detail/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/demo/list-detail/src/main/res/values/strings.xml b/demo/list-detail/src/main/res/values/strings.xml new file mode 100644 index 0000000000..03ea05746c --- /dev/null +++ b/demo/list-detail/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + + + List Detail Demo + diff --git a/demo/list-detail/src/main/res/values/themes.xml b/demo/list-detail/src/main/res/values/themes.xml new file mode 100644 index 0000000000..7e7c1d9610 --- /dev/null +++ b/demo/list-detail/src/main/res/values/themes.xml @@ -0,0 +1,10 @@ + + + + + +