mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-25 05:27:42 +00:00
Rewrite bottom navigation in compose.
This commit is contained in:
committed by
Cody Henthorne
parent
a9ed6b6154
commit
32b710a3ca
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<View>(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<View>(R.id.calls_tab_touch_point).setOnClickListener {
|
||||
viewModel.onCallsSelected()
|
||||
}
|
||||
|
||||
view.findViewById<View>(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<LottieAnimationView>) {
|
||||
toAnimate.forEach {
|
||||
if (it.isSelected) {
|
||||
it.resumeAnimation()
|
||||
} else {
|
||||
if (it.isAnimating) {
|
||||
it.pauseAnimation()
|
||||
}
|
||||
|
||||
it.progress = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runPillAnimation(duration: Long, toAnimate: List<ImageView>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/signal_colorSurface2"
|
||||
android:minHeight="80dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/chats_tab_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/calls_tab_container"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/calls_tab_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/stories_tab_container"
|
||||
app:layout_constraintStart_toEndOf="@id/chats_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/stories_tab_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/calls_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/chats_tab_touch_point"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_container"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/calls_tab_touch_point"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_container"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/stories_tab_touch_point"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_container"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/chats_pill"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="32dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/conversation_tab_icon_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/chats_tab_icon"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/chats_tab_icon" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/calls_pill"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="32dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/conversation_tab_icon_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/calls_tab_icon"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/calls_tab_icon" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/stories_pill"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="32dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:src="@drawable/conversation_tab_icon_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/stories_tab_icon"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/stories_tab_icon" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/chats_tab_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toTopOf="@id/chats_tab_label"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_touch_point"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_touch_point"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:lottie_rawRes="@raw/chats_28" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chats_tab_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/ConversationListTabs__chats"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_colorOnBackground"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/chats_tab_icon" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/calls_tab_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toTopOf="@id/calls_tab_label"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_touch_point"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_touch_point"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:lottie_rawRes="@raw/calls_28" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/calls_tab_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/ConversationListTabs__calls"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_colorOnBackground"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/calls_tab_icon" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/stories_tab_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toTopOf="@id/stories_tab_label"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_touch_point"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_touch_point"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:lottie_rawRes="@raw/stories_28" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stories_tab_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/ConversationListTabs__stories"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_colorOnBackground"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/stories_tab_icon" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chats_unread_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16sp"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/tab_unread_circle"
|
||||
android:gravity="center_horizontal"
|
||||
android:minWidth="16sp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:textAppearance="@style/Signal.Text.Caption"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||
app:layout_constraintBottom_toBottomOf="@id/chats_tab_icon"
|
||||
tools:text="3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/calls_unread_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16sp"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/tab_unread_circle"
|
||||
android:gravity="center_horizontal"
|
||||
android:minWidth="16sp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:textAppearance="@style/Signal.Text.Caption"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/calls_tab_icon"
|
||||
tools:text="3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stories_unread_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16sp"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/tab_unread_circle"
|
||||
android:gravity="center_horizontal"
|
||||
android:minWidth="16sp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:textAppearance="@style/Signal.Text.Caption"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/stories_tab_icon"
|
||||
tools:text="99+" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,232 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/signal_colorSurface2"
|
||||
android:minHeight="48dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/chats_tab_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/calls_tab_container"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/calls_tab_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/stories_tab_container"
|
||||
app:layout_constraintStart_toEndOf="@id/chats_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/stories_tab_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/calls_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/chats_tab_touch_point"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_container"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/calls_tab_touch_point"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_container"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/stories_tab_touch_point"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_container"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_container"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/chats_pill"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="30dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/conversation_tab_icon_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/chats_tab_icon"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/chats_tab_icon" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/calls_pill"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="30dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/conversation_tab_icon_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/calls_tab_icon"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/calls_tab_icon" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/stories_pill"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="30dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/conversation_tab_icon_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/stories_tab_icon"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/stories_tab_icon" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/chats_tab_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toTopOf="@id/chats_tab_label"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_touch_point"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_touch_point"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:lottie_rawRes="@raw/chats_28" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chats_tab_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/ConversationListTabs__chats"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_colorOnBackground"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/chats_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/chats_tab_icon" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/calls_tab_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toTopOf="@id/calls_tab_label"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_touch_point"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_touch_point"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:lottie_rawRes="@raw/calls_28" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/calls_tab_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/ConversationListTabs__calls"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_colorOnBackground"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/calls_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/calls_tab_icon" />
|
||||
|
||||
<com.airbnb.lottie.LottieAnimationView
|
||||
android:id="@+id/stories_tab_icon"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerInside"
|
||||
app:layout_constraintBottom_toTopOf="@id/stories_tab_label"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_touch_point"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_touch_point"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:lottie_rawRes="@raw/stories_28" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stories_tab_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/ConversationListTabs__stories"
|
||||
android:textAppearance="@style/TextAppearance.Signal.Body2"
|
||||
android:textColor="@color/signal_colorOnBackground"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/stories_tab_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
||||
app:layout_constraintTop_toBottomOf="@id/stories_tab_icon" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/chats_unread_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16sp"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/tab_unread_circle"
|
||||
android:gravity="center_horizontal"
|
||||
android:minWidth="16sp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:textAppearance="@style/Signal.Text.Caption"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintStart_toStartOf="@id/chats_tab_icon"
|
||||
app:layout_constraintBottom_toBottomOf="@id/chats_tab_icon"
|
||||
tools:text="3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/calls_unread_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16sp"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/tab_unread_circle"
|
||||
android:gravity="center_horizontal"
|
||||
android:minWidth="16sp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:textAppearance="@style/Signal.Text.Caption"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintStart_toStartOf="@id/calls_tab_icon"
|
||||
app:layout_constraintBottom_toBottomOf="@id/calls_tab_icon"
|
||||
tools:text="3" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/stories_unread_indicator"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="16sp"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/tab_unread_circle"
|
||||
android:gravity="center_horizontal"
|
||||
android:minWidth="16sp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:textAppearance="@style/Signal.Text.Caption"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintStart_toStartOf="@id/stories_tab_icon"
|
||||
app:layout_constraintBottom_toBottomOf="@id/stories_tab_icon"
|
||||
tools:text="99+" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -698,6 +698,30 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="89a673451d542de4819c0da6c0c680f5cc15d93e234eb67a3feed3a1a774d348" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive" version="1.0.0">
|
||||
<artifact name="adaptive-1.0.0.module">
|
||||
<sha256 value="f7c0e6c85e7499bd4dc16050783b68acf5c946d127687fa61e0c490885b09142" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="adaptive-metadata-1.0.0.jar">
|
||||
<sha256 value="4d0d435b144606c9431d844993ae8e7fdeaa3b49b917382fa07add3e7e29aebb" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive-android" version="1.0.0">
|
||||
<artifact name="adaptive-android-1.0.0.module">
|
||||
<sha256 value="f2a0e2be8c962b7e8944627fa075a09a46fca7e7a0c367643c4889be6435cf0a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="adaptive-release.aar">
|
||||
<sha256 value="d1a8c84e6947220f3d1843a725460d7d16a8c05ddba6168193455771a9eb96d0" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.material3.adaptive" name="adaptive-desktop" version="1.0.0">
|
||||
<artifact name="adaptive-desktop-1.0.0.jar">
|
||||
<sha256 value="1857cd739d45cb4ae5a9d79653285cf125068db5dfcbd7f2bf6963f208a8769b" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="adaptive-desktop-1.0.0.module">
|
||||
<sha256 value="f9cffc9236455e3cb5986297c1781883d2c354a19b123b77ac2f452903e4f308" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.runtime" name="runtime" version="1.0.1">
|
||||
<artifact name="runtime-1.0.1.module">
|
||||
<sha256 value="2543a8c7edc16bde91f140286b4fd3773d7204a283a4ec99f6e5e286aa92c0c3" origin="Generated by Gradle"/>
|
||||
@@ -2741,6 +2765,30 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="8bdd866119755df849bb7710161437e836888c3aadb18b884f49b67b254434bd" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.window" name="window-core" version="1.3.0">
|
||||
<artifact name="window-core-1.3.0.module">
|
||||
<sha256 value="7410a607fc8cc30af9b8fb0ae0783063d24c0e5575bbc105b938ebd26fbb387f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="window-core-metadata-1.3.0.jar">
|
||||
<sha256 value="3cc622558f9fe0dce8af12224f3ec921df332f67a416714bb66aea7faa6b21dc" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.window" name="window-core-android" version="1.3.0">
|
||||
<artifact name="window-core-android-1.3.0.module">
|
||||
<sha256 value="479f25789d4ed1ab41e29af4dd31c52b6c684c1f04aec06981e64a3c39543cf6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="window-core-release.aar">
|
||||
<sha256 value="7e0a44287cc647b2c8580d4ed72a9a511adddd9dc8387fd202127a9ea6862bf7" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.window" name="window-core-jvm" version="1.3.0">
|
||||
<artifact name="window-core-jvm-1.3.0.jar">
|
||||
<sha256 value="12faee8e75da9e25ad2afdf0e733f348530ec38f7bb6edcf3fbb5c31257a83f6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="window-core-jvm-1.3.0.module">
|
||||
<sha256 value="81403484e926aac83e86d39641d1082756c052319d750badc53d3dda5ea55ec6" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.window" name="window-java" version="1.3.0">
|
||||
<artifact name="window-java-1.3.0.aar">
|
||||
<sha256 value="d41b0e3e17efe85b426069a19dab0777ac24222edebced1d6747b2ff2e123c61" origin="Generated by Gradle"/>
|
||||
|
||||
Reference in New Issue
Block a user