From 32b710a3caae4ee3ff213a5d8690bc915bfb77af Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 24 Mar 2025 14:34:27 -0300 Subject: [PATCH] Rewrite bottom navigation in compose. --- .../securesms/main/MainNavigation.kt | 382 ++++++++++++++++++ .../tabs/ConversationListTabsFragment.kt | 264 ++---------- .../stories/tabs/ConversationListTabsState.kt | 5 +- .../tabs/ConversationListTabsViewModel.kt | 5 + .../securesms/window/AppScaffold.kt | 191 +++++++++ .../res/layout/conversation_list_tabs.xml | 230 ----------- .../layout/conversation_list_tabs_small.xml | 232 ----------- core-ui/build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 48 +++ 10 files changed, 669 insertions(+), 690 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt delete mode 100644 app/src/main/res/layout/conversation_list_tabs.xml delete mode 100644 app/src/main/res/layout/conversation_list_tabs_small.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt new file mode 100644 index 0000000000..65fb500842 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt @@ -0,0 +1,382 @@ +/* + * Copyright 2025 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.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +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.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +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.graphics.vector.ImageVector +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.res.vectorResource +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +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.Previews +import org.signal.core.ui.compose.SignalPreview +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R + +private val LOTTIE_SIZE = 28.dp + +enum class MainNavigationDestination( + @StringRes val label: Int, + @RawRes val icon: Int, + @StringRes val contentDescription: Int = label +) { + CHATS( + 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 + ) +} + +data class MainNavigationState( + val chatsCount: Int = 0, + val callsCount: Int = 0, + val storiesCount: Int = 0, + val storyFailure: Boolean = false, + val selectedDestination: MainNavigationDestination = MainNavigationDestination.CHATS, + val compact: Boolean = false +) + +/** + * Chats list bottom navigation bar. + */ +@Composable +fun MainNavigationBar( + state: MainNavigationState, + onDestinationSelected: (MainNavigationDestination) -> Unit +) { + NavigationBar( + containerColor = SignalTheme.colors.colorSurface2, + contentColor = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.height(if (state.compact) 48.dp else 80.dp) + ) { + MainNavigationDestination.entries.forEach { destination -> + + val badgeCount = when (destination) { + MainNavigationDestination.CHATS -> state.chatsCount + MainNavigationDestination.CALLS -> state.callsCount + MainNavigationDestination.STORIES -> state.storiesCount + } + + val selected = state.selectedDestination == 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 + + 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(20f, 20f) + ) + + 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, + onDestinationSelected: (MainNavigationDestination) -> Unit +) { + NavigationRail( + containerColor = SignalTheme.colors.colorSurface1, + header = { + FilledTonalIconButton( + onClick = { }, + shape = RoundedCornerShape(18.dp), + modifier = Modifier + .padding(top = 56.dp, bottom = 16.dp) + .size(56.dp), + enabled = true, + colors = IconButtonDefaults.filledTonalIconButtonColors() + .copy( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onBackground + ) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_edit_24), + contentDescription = null + ) + } + + FilledTonalIconButton( + onClick = { }, + shape = RoundedCornerShape(18.dp), + modifier = Modifier + .padding(bottom = 80.dp) + .size(56.dp), + enabled = true, + colors = IconButtonDefaults.filledTonalIconButtonColors() + .copy( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_camera_24), + contentDescription = null + ) + } + } + ) { + MainNavigationDestination.entries.forEachIndexed { idx, destination -> + val selected = state.selectedDestination == destination + + Box { + NavigationRailItem( + modifier = Modifier.padding(bottom = if (MainNavigationDestination.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: MainNavigationDestination +) { + val count = remember(state, destination) { + when (destination) { + MainNavigationDestination.CHATS -> state.chatsCount + MainNavigationDestination.CALLS -> state.callsCount + MainNavigationDestination.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: MainNavigationDestination, + 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(if (selected) 1f else 0f) + + LottieAnimation( + composition = composition, + progress = { progress }, + dynamicProperties = dynamicProperties, + modifier = Modifier.size(LOTTIE_SIZE) + ) +} + +@Composable +private fun NavigationDestinationLabel(destination: MainNavigationDestination) { + Text(stringResource(destination.label)) +} + +@Composable +private fun formatCount(count: Int): String { + if (count > 99) { + return stringResource(R.string.ConversationListTabs__99p) + } + return count.toString() +} + +@SignalPreview +@Composable +private fun MainNavigationRailPreview() { + Previews.Preview { + var selected by remember { mutableStateOf(MainNavigationDestination.CHATS) } + + MainNavigationRail( + state = MainNavigationState( + chatsCount = 500, + callsCount = 10, + storiesCount = 5, + selectedDestination = selected + ), + onDestinationSelected = { selected = it } + ) + } +} + +@SignalPreview +@Composable +private fun MainNavigationBarPreview() { + Previews.Preview { + var selected by remember { mutableStateOf(MainNavigationDestination.CHATS) } + + MainNavigationBar( + state = MainNavigationState( + chatsCount = 500, + callsCount = 10, + storiesCount = 5, + selectedDestination = selected, + compact = false + ), + onDestinationSelected = { selected = it } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt index f9c8b8f827..a73be1a8bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsFragment.kt @@ -1,247 +1,57 @@ package org.thoughtcrime.securesms.stories.tabs -import android.animation.Animator -import android.animation.AnimatorSet -import android.animation.ValueAnimator -import android.os.Bundle -import android.view.View -import android.widget.ImageView -import androidx.constraintlayout.widget.ConstraintSet -import androidx.core.content.ContextCompat -import androidx.core.view.animation.PathInterpolatorCompat -import androidx.fragment.app.Fragment +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rxjava3.subscribeAsState import androidx.fragment.app.viewModels -import com.airbnb.lottie.LottieAnimationView -import com.airbnb.lottie.LottieProperty -import com.airbnb.lottie.model.KeyPath -import io.reactivex.rxjava3.kotlin.subscribeBy -import org.signal.core.util.DimensionUnit -import org.signal.core.util.concurrent.LifecycleDisposable -import org.signal.core.util.dp -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.ViewBinderDelegate -import org.thoughtcrime.securesms.databinding.ConversationListTabsBinding -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.stories.Stories -import org.thoughtcrime.securesms.util.visible +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.main.MainNavigationBar +import org.thoughtcrime.securesms.main.MainNavigationDestination +import org.thoughtcrime.securesms.main.MainNavigationState /** * Displays the "Chats" and "Stories" tab to a user. */ -class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) { +class ConversationListTabsFragment : ComposeFragment() { private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() }) - private val disposables: LifecycleDisposable = LifecycleDisposable() - private val binding by ViewBinderDelegate(ConversationListTabsBinding::bind) - private var shouldBeImmediate = true - private var pillAnimator: Animator? = null - private val largeConstraintSet: ConstraintSet = ConstraintSet() - private val smallConstraintSet: ConstraintSet = ConstraintSet() + @Composable + override fun FragmentContent() { + val state by viewModel.state.subscribeAsState(ConversationListTabsState()) - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - disposables.bindTo(viewLifecycleOwner) - - val iconTint = ContextCompat.getColor(requireContext(), R.color.signal_colorOnSecondaryContainer) - - largeConstraintSet.clone(binding.root) - smallConstraintSet.clone(requireContext(), R.layout.conversation_list_tabs_small) - - binding.chatsTabIcon.addValueCallback( - KeyPath("**"), - LottieProperty.COLOR - ) { iconTint } - - binding.callsTabIcon.addValueCallback( - KeyPath("**"), - LottieProperty.COLOR - ) { iconTint } - - binding.storiesTabIcon.addValueCallback( - KeyPath("**"), - LottieProperty.COLOR - ) { iconTint } - - view.findViewById(R.id.chats_tab_touch_point).setOnClickListener { - viewModel.onChatsSelected() + val navState = remember(state) { + MainNavigationState( + chatsCount = state.unreadMessagesCount.toInt(), + callsCount = state.unreadCallsCount.toInt(), + storiesCount = state.unreadStoriesCount.toInt(), + storyFailure = state.hasFailedStory, + selectedDestination = when (state.tab) { + ConversationListTab.CHATS -> MainNavigationDestination.CHATS + ConversationListTab.CALLS -> MainNavigationDestination.CALLS + ConversationListTab.STORIES -> MainNavigationDestination.STORIES + }, + compact = state.compact + ) } - view.findViewById(R.id.calls_tab_touch_point).setOnClickListener { - viewModel.onCallsSelected() - } - - view.findViewById(R.id.stories_tab_touch_point).setOnClickListener { - viewModel.onStoriesSelected() - } - - updateTabsVisibility() - - disposables += viewModel.state.subscribeBy { - update(it, shouldBeImmediate) - shouldBeImmediate = false + if (state.visibilityState.isVisible()) { + MainNavigationBar( + state = navState, + onDestinationSelected = { + when (it) { + MainNavigationDestination.CHATS -> viewModel.onChatsSelected() + MainNavigationDestination.CALLS -> viewModel.onCallsSelected() + MainNavigationDestination.STORIES -> viewModel.onStoriesSelected() + } + } + ) } } override fun onResume() { super.onResume() - updateTabsVisibility() - } - - private fun updateTabsVisibility() { - if (SignalStore.settings.useCompactNavigationBar) { - smallConstraintSet.applyTo(binding.root) - binding.root.minHeight = 48.dp - } else { - largeConstraintSet.applyTo(binding.root) - binding.root.minHeight = 80.dp - } - - listOf( - binding.callsPill, - binding.callsTabIcon, - binding.callsTabContainer, - binding.callsTabLabel, - binding.callsUnreadIndicator, - binding.callsTabTouchPoint - ).forEach { - it.visible = true - } - - listOf( - binding.storiesPill, - binding.storiesTabIcon, - binding.storiesTabContainer, - binding.storiesTabLabel, - binding.storiesUnreadIndicator, - binding.storiesTabTouchPoint - ).forEach { - it.visible = Stories.isFeatureEnabled() - } - - if (SignalStore.settings.useCompactNavigationBar) { - listOf( - binding.callsTabLabel, - binding.chatsTabLabel, - binding.storiesTabLabel - ).forEach { - it.visible = false - } - } - - update(viewModel.stateSnapshot, true) - } - - private fun update(state: ConversationListTabsState, immediate: Boolean) { - binding.chatsTabIcon.isSelected = state.tab == ConversationListTab.CHATS - binding.chatsPill.isSelected = state.tab == ConversationListTab.CHATS - - if (Stories.isFeatureEnabled()) { - binding.storiesTabIcon.isSelected = state.tab == ConversationListTab.STORIES - binding.storiesPill.isSelected = state.tab == ConversationListTab.STORIES - } - - binding.callsTabIcon.isSelected = state.tab == ConversationListTab.CALLS - binding.callsPill.isSelected = state.tab == ConversationListTab.CALLS - - val hasStateChange = state.tab != state.prevTab - if (immediate) { - binding.chatsTabIcon.pauseAnimation() - binding.chatsTabIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f - - if (Stories.isFeatureEnabled()) { - binding.storiesTabIcon.pauseAnimation() - binding.storiesTabIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f - } - - binding.callsTabIcon.pauseAnimation() - binding.callsTabIcon.progress = if (state.tab == ConversationListTab.CALLS) 1f else 0f - - runPillAnimation( - 0, - listOfNotNull( - binding.chatsPill, - binding.callsPill, - binding.storiesPill.takeIf { Stories.isFeatureEnabled() } - ) - ) - } else if (hasStateChange) { - runLottieAnimations( - listOfNotNull( - binding.chatsTabIcon, - binding.callsTabIcon, - binding.storiesTabIcon.takeIf { Stories.isFeatureEnabled() } - ) - ) - - runPillAnimation( - 150, - listOfNotNull( - binding.chatsPill, - binding.callsPill, - binding.storiesPill.takeIf { Stories.isFeatureEnabled() } - ) - ) - } - - binding.chatsUnreadIndicator.visible = state.unreadMessagesCount > 0 - binding.chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount) - - if (Stories.isFeatureEnabled()) { - binding.storiesUnreadIndicator.visible = state.unreadStoriesCount > 0 || state.hasFailedStory - binding.storiesUnreadIndicator.text = if (state.hasFailedStory) "!" else formatCount(state.unreadStoriesCount) - } - - binding.callsUnreadIndicator.visible = state.unreadCallsCount > 0 - binding.callsUnreadIndicator.text = formatCount(state.unreadCallsCount) - - requireView().visible = state.visibilityState.isVisible() - } - - private fun runLottieAnimations(toAnimate: List) { - toAnimate.forEach { - if (it.isSelected) { - it.resumeAnimation() - } else { - if (it.isAnimating) { - it.pauseAnimation() - } - - it.progress = 0f - } - } - } - - private fun runPillAnimation(duration: Long, toAnimate: List) { - val (selected, unselected) = toAnimate.partition { it.isSelected } - - pillAnimator?.cancel() - pillAnimator = AnimatorSet().apply { - this.duration = duration - interpolator = PathInterpolatorCompat.create(0.17f, 0.17f, 0f, 1f) - playTogether( - selected.map { view -> - view.visibility = View.VISIBLE - ValueAnimator.ofInt(view.paddingLeft, 0).apply { - addUpdateListener { - view.setPadding(it.animatedValue as Int, 0, it.animatedValue as Int, 0) - } - } - } - ) - start() - } - - unselected.forEach { - val smallPad = DimensionUnit.DP.toPixels(16f).toInt() - it.setPadding(smallPad, 0, smallPad, 0) - it.visibility = View.INVISIBLE - } - } - - private fun formatCount(count: Long): String { - if (count > 99L) { - return getString(R.string.ConversationListTabs__99p) - } - return count.toString() + viewModel.refreshNavigationBarState() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt index a7f41bec6f..54da5d8fbf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsState.kt @@ -1,5 +1,7 @@ package org.thoughtcrime.securesms.stories.tabs +import org.thoughtcrime.securesms.keyvalue.SignalStore + data class ConversationListTabsState( val tab: ConversationListTab = ConversationListTab.CHATS, val prevTab: ConversationListTab = if (tab == ConversationListTab.CHATS) ConversationListTab.STORIES else ConversationListTab.CHATS, @@ -7,7 +9,8 @@ data class ConversationListTabsState( val unreadCallsCount: Long = 0L, val unreadStoriesCount: Long = 0L, val hasFailedStory: Boolean = false, - val visibilityState: VisibilityState = VisibilityState() + val visibilityState: VisibilityState = VisibilityState(), + val compact: Boolean = SignalStore.settings.useCompactNavigationBar ) { data class VisibilityState( val isSearchOpen: Boolean = false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt index 9575f3219f..e2dbbc3002 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabsViewModel.kt @@ -11,6 +11,7 @@ import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.Subject import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.rx.RxStore @@ -48,6 +49,10 @@ class ConversationListTabsViewModel(startingTab: ConversationListTab, repository } } + fun refreshNavigationBarState() { + store.update { it.copy(compact = SignalStore.settings.useCompactNavigationBar) } + } + override fun onCleared() { disposables.clear() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt new file mode 100644 index 0000000000..331c553ff8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.window + +import android.content.res.Configuration +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.width +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.window.core.layout.WindowHeightSizeClass +import androidx.window.core.layout.WindowWidthSizeClass +import org.signal.core.ui.compose.Previews +import org.thoughtcrime.securesms.main.MainNavigationBar +import org.thoughtcrime.securesms.main.MainNavigationRail +import org.thoughtcrime.securesms.main.MainNavigationState + +enum class Navigation { + RAIL, + BAR +} + +/** + * Describes the size of screen we are displaying, and what components should be displayed. + * + * https://developer.android.com/develop/ui/compose/layouts/adaptive/use-window-size-classes + */ +enum class WindowSizeClass( + val navigation: Navigation +) { + COMPACT_PORTRAIT(Navigation.BAR), + COMPACT_LANDSCAPE(Navigation.BAR), + MEDIUM_PORTRAIT(Navigation.BAR), + MEDIUM_LANDSCAPE(Navigation.RAIL), + EXTENDED_PORTRAIT(Navigation.RAIL), + EXTENDED_LANDSCAPE(Navigation.RAIL); + + companion object { + @Composable + fun rememberWindowSizeClass(): WindowSizeClass { + val wsc = currentWindowAdaptiveInfo().windowSizeClass + val orientation = LocalConfiguration.current.orientation + + return remember(orientation, wsc) { + when (orientation) { + Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { + when (wsc.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> COMPACT_PORTRAIT + WindowWidthSizeClass.MEDIUM -> MEDIUM_PORTRAIT + WindowWidthSizeClass.EXPANDED -> EXTENDED_PORTRAIT + else -> error("Unsupported.") + } + } + Configuration.ORIENTATION_LANDSCAPE -> { + when (wsc.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> COMPACT_LANDSCAPE + WindowHeightSizeClass.MEDIUM -> MEDIUM_LANDSCAPE + WindowHeightSizeClass.EXPANDED -> EXTENDED_LANDSCAPE + else -> error("Unsupported.") + } + } + else -> error("Unexpected orientation: $orientation") + } + } + } + } +} + +/** + * Composable who's precise layout will depend on the window size class of the device it is being utilized on. + * This is built to be generic so that we can use it throughout the application to support different device classes. + */ +@Composable +fun AppScaffold( + listContent: @Composable () -> Unit, + detailContent: @Composable () -> Unit, + navRailContent: @Composable () -> Unit, + bottomNavContent: @Composable () -> Unit +) { + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + + Row { + if (windowSizeClass.navigation == Navigation.RAIL) { + navRailContent() + } + + BoxWithConstraints( + modifier = Modifier.weight(1f) + ) { + val listWidth = when (windowSizeClass) { + WindowSizeClass.COMPACT_PORTRAIT -> maxWidth + WindowSizeClass.COMPACT_LANDSCAPE -> maxWidth + WindowSizeClass.MEDIUM_PORTRAIT -> maxWidth * 0.5f + WindowSizeClass.MEDIUM_LANDSCAPE -> 360.dp + WindowSizeClass.EXTENDED_PORTRAIT -> 360.dp + WindowSizeClass.EXTENDED_LANDSCAPE -> 360.dp + } + + val detailWidth = maxWidth - listWidth + + Row { + Column( + modifier = Modifier.width(listWidth) + ) { + Box(modifier = Modifier.weight(1f)) { + listContent() + } + + if (windowSizeClass.navigation == Navigation.BAR) { + bottomNavContent() + } + } + + if (detailWidth > 0.dp) { + // TODO -- slider to divide sizing? + Box(modifier = Modifier.width(detailWidth)) { + detailContent() + } + } + } + } + } +} + +@Preview(device = "spec:width=360dp,height=640dp,orientation=portrait") +@Preview(device = "spec:width=640dp,height=360dp,orientation=landscape") +@Preview(device = "spec:width=600dp,height=1024dp,orientation=portrait") +@Preview(device = "spec:width=1024dp,height=600dp,orientation=landscape") +@Preview(device = "spec:width=840dp,height=1280dp,orientation=portrait") +@Preview(device = "spec:width=1280dp,height=840dp,orientation=landscape") +@Composable +private fun AppScaffoldPreview() { + Previews.Preview { + AppScaffold( + listContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(color = Color.Red) + ) { + Text( + text = "ListContent", + textAlign = TextAlign.Center + ) + } + }, + detailContent = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background(color = Color.Blue) + ) { + Text( + text = "DetailContent", + textAlign = TextAlign.Center + ) + } + }, + navRailContent = { + MainNavigationRail( + state = MainNavigationState(), + onDestinationSelected = {} + ) + }, + bottomNavContent = { + MainNavigationBar( + state = MainNavigationState(), + onDestinationSelected = {} + ) + } + ) + } +} diff --git a/app/src/main/res/layout/conversation_list_tabs.xml b/app/src/main/res/layout/conversation_list_tabs.xml deleted file mode 100644 index 9769222a1e..0000000000 --- a/app/src/main/res/layout/conversation_list_tabs.xml +++ /dev/null @@ -1,230 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_list_tabs_small.xml b/app/src/main/res/layout/conversation_list_tabs_small.xml deleted file mode 100644 index 31cb0bf2df..0000000000 --- a/app/src/main/res/layout/conversation_list_tabs_small.xml +++ /dev/null @@ -1,232 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/core-ui/build.gradle.kts b/core-ui/build.gradle.kts index 11c80e9ca4..a8d21ba7db 100644 --- a/core-ui/build.gradle.kts +++ b/core-ui/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { } api(libs.androidx.compose.material3) + api(libs.androidx.compose.material3.adaptive) api(libs.androidx.compose.ui.tooling.preview) debugApi(libs.androidx.compose.ui.tooling.core) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 96e32c01e0..d203a3623f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ androidx-benchmark-gradle-plugin = "androidx.benchmark:benchmark-gradle-plugin:1 # Compose androidx-compose-bom = "androidx.compose:compose-bom:2024.12.01" androidx-compose-material3 = { module = "androidx.compose.material3:material3" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive"} androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } androidx-compose-ui-tooling-core = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index dabd6af9c5..59e53ca55c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -698,6 +698,30 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + + @@ -2741,6 +2765,30 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + + + + + + + + + + + + + + +