diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt
index 5784b8a723..cc371c5164 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/SelectContactsViewModel.kt
@@ -1,11 +1,13 @@
package org.thoughtcrime.securesms.groups
+import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
@@ -15,11 +17,14 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
+import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.shouldShowProBadge
@@ -27,6 +32,7 @@ import org.thoughtcrime.securesms.database.RecipientRepository
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.search.searchName
import org.thoughtcrime.securesms.pro.ProStatusManager
+import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.util.AvatarUIData
import org.thoughtcrime.securesms.util.AvatarUtils
@@ -39,6 +45,7 @@ open class SelectContactsViewModel @AssistedInject constructor(
@Assisted private val excludingAccountIDs: Set
,
@Assisted private val contactFiltering: (Recipient) -> Boolean, // default will filter out blocked and unapproved contacts
private val recipientRepository: RecipientRepository,
+ @param:ApplicationContext private val context: Context,
) : ViewModel() {
// Input: The search query
private val mutableSearchQuery = MutableStateFlow("")
@@ -71,6 +78,27 @@ open class SelectContactsViewModel @AssistedInject constructor(
val currentSelected: Set
get() = mutableSelectedContactAccountIDs.value
+ private val footerCollapsed = MutableStateFlow(false)
+
+ val collapsibleFooterState: StateFlow =
+ combine(mutableSelectedContactAccountIDs, footerCollapsed) { selected, isCollapsed ->
+ val count = selected.size
+ val visible = count > 0
+ val title = if (count == 0) GetString("")
+ else GetString(
+ context.resources.getQuantityString(R.plurals.contactSelected, count, count)
+ )
+
+ CollapsibleFooterState(
+ visible = visible,
+ // auto-expand when nothing is selected, otherwise keep user's choice
+ collapsed = if (!visible) false else isCollapsed,
+ footerActionTitle = title
+ )
+ }
+ .distinctUntilChanged()
+ .stateIn(viewModelScope, SharingStarted.Eagerly, CollapsibleFooterState())
+
@OptIn(ExperimentalCoroutinesApi::class)
private fun observeContacts() = (configFactory.configUpdateNotifications as Flow)
.debounce(100L)
@@ -144,6 +172,16 @@ open class SelectContactsViewModel @AssistedInject constructor(
mutableSelectedContactAccountIDs.value = emptySet()
}
+ fun toggleFooter() {
+ footerCollapsed.update { !it }
+ }
+
+ data class CollapsibleFooterState(
+ val visible: Boolean = false,
+ val collapsed: Boolean = false,
+ val footerActionTitle : GetString = GetString("")
+ )
+
@AssistedFactory
interface Factory {
fun create(
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt
index 902c0bc4fe..b0ece45a94 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/compose/InviteContactsScreen.kt
@@ -4,10 +4,16 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -15,8 +21,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import network.loki.messenger.R
@@ -24,9 +32,12 @@ import org.session.libsession.utilities.Address
import org.thoughtcrime.securesms.groups.ContactItem
import org.thoughtcrime.securesms.groups.SelectContactsViewModel
import org.thoughtcrime.securesms.ui.BottomFadingEdgeBox
+import org.thoughtcrime.securesms.ui.CollapsibleFooterAction
+import org.thoughtcrime.securesms.ui.CollapsibleFooterActionData
+import org.thoughtcrime.securesms.ui.CollapsibleFooterItemData
+import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.SearchBar
import org.thoughtcrime.securesms.ui.components.BackAppBar
-import org.thoughtcrime.securesms.ui.components.AccentOutlineButton
import org.thoughtcrime.securesms.ui.qaTag
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
@@ -42,17 +53,23 @@ fun InviteContactsScreen(
viewModel: SelectContactsViewModel,
onDoneClicked: () -> Unit,
onBack: () -> Unit,
- banner: @Composable ()->Unit = {}
+ banner: @Composable () -> Unit = {}
) {
+ val footerData by viewModel.collapsibleFooterState.collectAsState()
+
InviteContacts(
contacts = viewModel.contacts.collectAsState().value,
onContactItemClicked = viewModel::onContactItemClicked,
searchQuery = viewModel.searchQuery.collectAsState().value,
onSearchQueryChanged = viewModel::onSearchQueryChanged,
- onSearchQueryClear = {viewModel.onSearchQueryChanged("") },
+ onSearchQueryClear = { viewModel.onSearchQueryChanged("") },
onDoneClicked = onDoneClicked,
onBack = onBack,
- banner = banner
+ banner = banner,
+ data = footerData,
+ onToggleFooter = viewModel::toggleFooter,
+ onCloseFooter = viewModel::clearSelection
+
)
}
@@ -66,15 +83,48 @@ fun InviteContacts(
onSearchQueryClear: () -> Unit,
onDoneClicked: () -> Unit,
onBack: () -> Unit,
- banner: @Composable ()->Unit = {}
+ banner: @Composable () -> Unit = {},
+ data: SelectContactsViewModel.CollapsibleFooterState,
+ onToggleFooter: () -> Unit,
+ onCloseFooter: () -> Unit,
) {
+ val colors = LocalColors.current
+ val trayItems = listOf(
+ CollapsibleFooterItemData(
+ label = GetString(LocalResources.current.getString(R.string.membersInvite)),
+ buttonLabel = GetString(LocalResources.current.getString(R.string.membersInviteTitle)),
+ buttonColor = colors.accent,
+ onClick = { onDoneClicked() }
+ )
+ )
+
Scaffold(
+ contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
BackAppBar(
title = stringResource(id = R.string.membersInvite),
onBack = onBack,
)
},
+ bottomBar = {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom))
+ .imePadding()
+ ) {
+ CollapsibleFooterAction(
+ data = CollapsibleFooterActionData(
+ title = data.footerActionTitle,
+ collapsed = data.collapsed,
+ visible = data.visible,
+ items = trayItems
+ ),
+ onCollapsedClicked = onToggleFooter,
+ onClosedClicked = onCloseFooter
+ )
+ }
+ }
) { paddings ->
Column(
modifier = Modifier
@@ -100,18 +150,19 @@ fun InviteContacts(
Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing))
- BottomFadingEdgeBox(modifier = Modifier.weight(1f)) { bottomContentPadding ->
- if(contacts.isEmpty() && searchQuery.isEmpty()){
+ Box(modifier = Modifier.weight(1f)) {
+ if (contacts.isEmpty() && searchQuery.isEmpty()) {
Text(
text = stringResource(id = R.string.contactNone),
- modifier = Modifier.padding(top = LocalDimensions.current.spacing)
+ modifier = Modifier
+ .padding(top = LocalDimensions.current.spacing)
.align(Alignment.TopCenter),
style = LocalType.current.base.copy(color = LocalColors.current.textSecondary)
)
} else {
LazyColumn(
state = scrollState,
- contentPadding = PaddingValues(bottom = bottomContentPadding),
+ contentPadding = PaddingValues(bottom = LocalDimensions.current.spacing),
) {
multiSelectMemberList(
contacts = contacts,
@@ -120,27 +171,7 @@ fun InviteContacts(
}
}
}
-
- Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing))
-
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier.fillMaxWidth()
- ) {
- AccentOutlineButton(
- onClick = onDoneClicked,
- enabled = contacts.any { it.selected },
- modifier = Modifier
- .padding(vertical = LocalDimensions.current.spacing)
- .qaTag(R.string.qa_invite_button),
- ) {
- Text(
- stringResource(id = R.string.membersInviteTitle)
- )
- }
- }
}
-
}
}
@@ -174,6 +205,13 @@ private fun PreviewSelectContacts() {
onSearchQueryClear = {},
onDoneClicked = {},
onBack = {},
+ data = SelectContactsViewModel.CollapsibleFooterState(
+ collapsed = false,
+ visible = true,
+ footerActionTitle = GetString("1 Contact Selected")
+ ),
+ onToggleFooter = { },
+ onCloseFooter = { },
)
}
}
@@ -192,7 +230,13 @@ private fun PreviewSelectEmptyContacts() {
onSearchQueryClear = {},
onDoneClicked = {},
onBack = {},
- banner = { GroupMinimumVersionBanner() }
+ data = SelectContactsViewModel.CollapsibleFooterState(
+ collapsed = true,
+ visible = false,
+ footerActionTitle = GetString("")
+ ),
+ onToggleFooter = { },
+ onCloseFooter = { }
)
}
}
@@ -211,7 +255,13 @@ private fun PreviewSelectEmptyContactsWithSearch() {
onSearchQueryClear = {},
onDoneClicked = {},
onBack = {},
- banner = { GroupMinimumVersionBanner() }
+ data = SelectContactsViewModel.CollapsibleFooterState(
+ collapsed = true,
+ visible = false,
+ footerActionTitle = GetString("")
+ ),
+ onToggleFooter = { },
+ onCloseFooter = { }
)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt
index 9a5ebeb878..2136ec045d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/home/startconversation/group/CreateGroupViewModel.kt
@@ -46,6 +46,7 @@ class CreateGroupViewModel @AssistedInject constructor(
excludingAccountIDs = emptySet(),
contactFiltering = SelectContactsViewModel.Factory.defaultFiltering,
recipientRepository = recipientRepository,
+ context = appContext
) {
// Child view model to handle contact selection logic
diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
index 4dc04a007c..cf2666cd9d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BlockedContactsViewModel.kt
@@ -34,6 +34,7 @@ class BlockedContactsViewModel @Inject constructor(
avatarUtils = avatarUtils,
proStatusManager = proStatusManager,
recipientRepository = recipientRepository,
+ context = context
) {
private val _unblockDialog = MutableStateFlow(false)
val unblockDialog: StateFlow = _unblockDialog
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt
index 14e4d43a06..2ad98b9ace 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt
@@ -3,13 +3,25 @@ package org.thoughtcrime.securesms.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.SizeTransform
import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -17,6 +29,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -26,11 +39,13 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
@@ -44,6 +59,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -55,6 +71,7 @@ import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -64,6 +81,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.dropShadow
+import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
@@ -79,6 +97,7 @@ import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.shadow.Shadow
+import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -88,10 +107,11 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.times
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -99,6 +119,7 @@ import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.components.AccentOutlineButton
import org.thoughtcrime.securesms.ui.components.SessionSwitch
+import org.thoughtcrime.securesms.ui.components.SlimFillButtonRect
import org.thoughtcrime.securesms.ui.components.SmallCircularProgressIndicator
import org.thoughtcrime.securesms.ui.components.TitledRadioButton
import org.thoughtcrime.securesms.ui.components.annotatedStringResource
@@ -106,6 +127,8 @@ import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
+import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
+import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.primaryBlue
import org.thoughtcrime.securesms.ui.theme.primaryGreen
import org.thoughtcrime.securesms.ui.theme.primaryOrange
@@ -125,11 +148,11 @@ fun AccountIdHeader(
horizontal = LocalDimensions.current.contentSpacing,
vertical = LocalDimensions.current.xxsSpacing
)
-){
+) {
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
- ){
+ ) {
Box(
modifier = Modifier
.weight(1f)
@@ -142,8 +165,7 @@ fun AccountIdHeader(
.border(
shape = MaterialTheme.shapes.large
)
- .padding(textPaddingValues)
- ,
+ .padding(textPaddingValues),
text = text,
style = textStyle.copy(color = LocalColors.current.textSecondary)
)
@@ -202,7 +224,7 @@ fun PathDot(
@Preview
@Composable
-fun PreviewPathDot(){
+fun PreviewPathDot() {
PreviewTheme {
Box(
modifier = Modifier.padding(20.dp)
@@ -227,8 +249,11 @@ data class OptionsCardData(
val title: GetString?,
val options: List>
) {
- constructor(title: GetString, vararg options: RadioOption): this(title, options.asList())
- constructor(@StringRes title: Int, vararg options: RadioOption): this(GetString(title), options.asList())
+ constructor(title: GetString, vararg options: RadioOption) : this(title, options.asList())
+ constructor(@StringRes title: Int, vararg options: RadioOption) : this(
+ GetString(title),
+ options.asList()
+ )
}
@Composable
@@ -286,7 +311,8 @@ fun ItemButton(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = iconTint ?: colors.contentColor,
- modifier = Modifier.align(Alignment.Center)
+ modifier = Modifier
+ .align(Alignment.Center)
.size(iconSize)
)
},
@@ -318,7 +344,8 @@ fun ItemButton(
onClick: () -> Unit
) {
TextButton(
- modifier = modifier.fillMaxWidth()
+ modifier = modifier
+ .fillMaxWidth()
.heightIn(min = minHeight),
colors = colors,
onClick = onClick,
@@ -351,14 +378,15 @@ fun ItemButton(
subtitle?.let {
Text(
text = it,
- modifier = Modifier.fillMaxWidth()
+ modifier = Modifier
+ .fillMaxWidth()
.qaTag(subtitleQaTag),
style = LocalType.current.small,
)
}
}
- endIcon?.let{
+ endIcon?.let {
Spacer(Modifier.width(LocalDimensions.current.smallSpacing))
Box(
@@ -392,7 +420,7 @@ fun Cell(
Box(
modifier = modifier
.then(
- if(dropShadow)
+ if (dropShadow)
Modifier.dropShadow(
shape = MaterialTheme.shapes.small,
shadow = Shadow(
@@ -425,7 +453,7 @@ fun getCellTopShape() = RoundedCornerShape(
@Composable
fun getCellBottomShape() = RoundedCornerShape(
- topStart = 0.dp,
+ topStart = 0.dp,
topEnd = 0.dp,
bottomEnd = LocalDimensions.current.shapeSmall,
bottomStart = LocalDimensions.current.shapeSmall
@@ -439,11 +467,11 @@ fun CategoryCell(
dropShadow: Boolean = false,
content: @Composable () -> Unit,
-){
+ ) {
Column(
modifier = modifier.fillMaxWidth()
) {
- if(!title.isNullOrEmpty() || titleIcon != null) {
+ if (!title.isNullOrEmpty() || titleIcon != null) {
Row(
modifier = Modifier.padding(
start = LocalDimensions.current.smallSpacing,
@@ -464,12 +492,12 @@ fun CategoryCell(
}
}
- Cell(
- modifier = Modifier.fillMaxWidth(),
- dropShadow = dropShadow
- ){
+ Cell(
+ modifier = Modifier.fillMaxWidth(),
+ dropShadow = dropShadow
+ ) {
content()
- }
+ }
}
}
@@ -510,9 +538,11 @@ private fun BottomFadingEdgeBoxPreview() {
content = { bottomContentPadding ->
LazyColumn(contentPadding = PaddingValues(bottom = bottomContentPadding)) {
items(200) {
- Text("Item $it",
+ Text(
+ "Item $it",
color = LocalColors.current.text,
- style = LocalType.current.base)
+ style = LocalType.current.base
+ )
}
}
},
@@ -631,7 +661,7 @@ fun SpeechBubbleTooltip(
state = tooltipState,
modifier = modifier,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
- tooltip = {
+ tooltip = {
val bubbleColor = LocalColors.current.backgroundBubbleReceived
Card(
@@ -731,6 +761,279 @@ fun SearchBar(
)
}
+/**
+ * CollapsibleFooterAction
+ */
+@Composable
+fun CollapsibleFooterAction(
+ modifier: Modifier = Modifier,
+ data: CollapsibleFooterActionData,
+ onCollapsedClicked: () -> Unit = {},
+ onClosedClicked: () -> Unit = {}
+) {
+
+ // Bottomsheet-like enter/exit
+ val enterFromBottom = remember {
+ slideInVertically(
+ // start completely off-screen below
+ initialOffsetY = { it },
+ animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
+ ) + fadeIn()
+ }
+ val exitToBottom = remember {
+ slideOutVertically(
+ targetOffsetY = { it },
+ animationSpec = tween(durationMillis = 200, easing = FastOutLinearInEasing)
+ ) + fadeOut()
+ }
+
+ AnimatedVisibility(
+ // drives show/hide from bottom
+ visible = data.visible,
+ enter = enterFromBottom,
+ exit = exitToBottom,
+ ) {
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(
+ RoundedCornerShape(
+ topStart = LocalDimensions.current.contentSpacing,
+ topEnd = LocalDimensions.current.contentSpacing
+ )
+ )
+ .background(LocalColors.current.backgroundSecondary)
+ .animateContentSize()
+ .padding(
+ horizontal = LocalDimensions.current.smallSpacing,
+ vertical = LocalDimensions.current.xxsSpacing
+ ),
+ verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ val rotation by animateFloatAsState(
+ targetValue = if (data.collapsed) 180f else 0f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ )
+
+ IconButton(
+ modifier = Modifier.rotate(rotation),
+ onClick = onCollapsedClicked
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_chevron_down),
+ contentDescription = null
+ )
+ }
+ Text(
+ text = data.title.string(),
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(horizontal = LocalDimensions.current.smallSpacing),
+ style = LocalType.current.h8,
+ textAlign = TextAlign.Center,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ IconButton(
+ onClick = onClosedClicked
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_x),
+ contentDescription = null
+ )
+ }
+ }
+
+ val showActions = data.visible && !data.collapsed
+ // Rendered actions
+ AnimatedVisibility(
+ visible = showActions,
+ enter = expandVertically(
+ animationSpec = tween(durationMillis = 150, easing = FastOutSlowInEasing),
+ expandFrom = Alignment.Top
+ ) + fadeIn(animationSpec = tween(durationMillis = 120)),
+ exit = shrinkVertically(
+ animationSpec = tween(durationMillis = 100, easing = FastOutLinearInEasing),
+ shrinkTowards = Alignment.Top
+ ) + fadeOut(animationSpec = tween(durationMillis = 80))
+ ) {
+ CategoryCell(modifier = Modifier.padding(bottom = LocalDimensions.current.smallSpacing)) {
+ CollapsibleFooterActions(items = data.items)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CollapsibleFooterActions(
+ items: List,
+ buttonWidthCapFraction: Float = 1f / 3f // criteria
+) {
+ // rules for this:
+ // Max width should be approx 1/3 of the available space (buttonWidthCapFraction)
+ // Buttons should have matching widths
+
+ BoxWithConstraints(Modifier.fillMaxWidth()) {
+ val density = LocalDensity.current
+ val capPx = (constraints.maxWidth * buttonWidthCapFraction).toInt()
+ val capDp = with(density) { capPx.toDp() }
+
+ val single = items.size == 1
+ val measuredMaxButtonWidthPx = remember(items, capPx) { mutableIntStateOf(1) }
+
+ // Only do the offscreen equal width computation when we have 2+ buttons.
+ if (!single) {
+ SubcomposeLayout { parentConstraints ->
+ val measurables = subcompose("measureButtons") {
+ items.forEach { item ->
+ SlimFillButtonRect(item.buttonLabel.string(), color = item.buttonColor) {}
+ }
+ }
+ val placeables = measurables.map { m ->
+ m.measure(
+ Constraints(
+ minWidth = 1,
+ maxWidth = capPx,
+ minHeight = 0,
+ maxHeight = parentConstraints.maxHeight
+ )
+ )
+ }
+ val natural = placeables.maxOfOrNull { it.width } ?: 1
+ measuredMaxButtonWidthPx.intValue = natural.coerceIn(1, capPx)
+ layout(0, 0) {}
+ }
+ }
+
+ val equalWidthDp = with(density) { measuredMaxButtonWidthPx.intValue.toDp() }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(LocalColors.current.backgroundTertiary)
+ ) {
+ items.forEachIndexed { index, item ->
+ if (index != 0) Divider()
+
+ val titleText = item.label()
+ val annotatedTitle = remember(titleText) { AnnotatedString(titleText) }
+
+ ActionRowItem(
+ modifier = Modifier.background(LocalColors.current.backgroundTertiary),
+ title = annotatedTitle,
+ onClick = {},
+ qaTag = R.string.qa_collapsing_footer_action,
+ endContent = {
+ Box(
+ modifier = Modifier
+ .padding(start = LocalDimensions.current.smallSpacing)
+ .then(
+ if (single) Modifier.wrapContentWidth().widthIn(max = capDp)
+ else Modifier.width(equalWidthDp)
+ )
+ ) {
+ SlimFillButtonRect(
+ modifier = if (single) Modifier else Modifier.fillMaxWidth(),
+ text = item.buttonLabel.string(),
+ color = item.buttonColor
+ ) { item.onClick() }
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+data class CollapsibleFooterActionData(
+ val title: GetString,
+ val collapsed: Boolean,
+ val visible: Boolean,
+ val items: List
+)
+
+data class CollapsibleFooterItemData(
+ val label: GetString,
+ val buttonLabel: GetString,
+ val buttonColor: Color,
+ val onClick: () -> Unit
+)
+
+
+@Preview
+@Composable
+fun PreviewCollapsibleActionTray(
+ @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
+) {
+ PreviewTheme(colors) {
+ val demoItems = listOf(
+ CollapsibleFooterItemData(
+ label = GetString("Invite "),
+ buttonLabel = GetString("Invite"),
+ buttonColor = LocalColors.current.accent,
+ onClick = {}
+ ),
+ CollapsibleFooterItemData(
+ label = GetString("Delete"),
+ buttonLabel = GetString("2"),
+ buttonColor = LocalColors.current.danger,
+ onClick = {}
+ )
+ )
+
+ CollapsibleFooterAction(
+ data = CollapsibleFooterActionData(
+ title = GetString("Invite Contacts"),
+ collapsed = false,
+ visible = true,
+ items = demoItems
+ )
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PreviewCollapsibleActionTrayLongText(
+ @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
+) {
+ PreviewTheme(colors) {
+ val demoItems = listOf(
+ CollapsibleFooterItemData(
+ label = GetString("Looooooooooooooooooooooooooooooooooooooooooooooooooooooooong"),
+ buttonLabel = GetString("Long Looooooooooooooooooooong"),
+ buttonColor = LocalColors.current.accent,
+ onClick = {}
+ ),
+ CollapsibleFooterItemData(
+ label = GetString("Delete"),
+ buttonLabel = GetString("Delete"),
+ buttonColor = LocalColors.current.danger,
+ onClick = {}
+ )
+ )
+
+ CollapsibleFooterAction(
+ data = CollapsibleFooterActionData(
+ title = GetString("Invite Contacts"),
+ collapsed = false,
+ visible = true,
+ items = demoItems
+ )
+ )
+ }
+}
+
@Preview
@Composable
fun PreviewSearchBar() {
@@ -761,14 +1064,15 @@ fun ExpandableText(
expandedMaxLines: Int = Int.MAX_VALUE,
expandButtonText: String = stringResource(id = R.string.viewMore),
collapseButtonText: String = stringResource(id = R.string.viewLess),
-){
+) {
var expanded by remember { mutableStateOf(false) }
var showButton by remember { mutableStateOf(false) }
var maxHeight by remember { mutableStateOf(Dp.Unspecified) }
val density = LocalDensity.current
- val enableScrolling = expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE
+ val enableScrolling =
+ expanded && maxHeight != Dp.Unspecified && expandedMaxLines != Int.MAX_VALUE
BaseExpandableText(
text = text,
@@ -789,10 +1093,11 @@ fun ExpandableText(
onTextMeasured = { textLayoutResult ->
showButton = expanded || textLayoutResult.hasVisualOverflow
val lastVisible = (expandedMaxLines - 1).coerceAtMost(textLayoutResult.lineCount - 1)
- val px = textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px
+ val px =
+ textLayoutResult.getLineBottom(lastVisible) // bottom of that line in px
maxHeight = with(density) { px.toDp() }
},
- onTap = if(showButton){ // only expand if there is enough text
+ onTap = if (showButton) { // only expand if there is enough text
{ expanded = !expanded }
} else null
)
@@ -851,11 +1156,11 @@ fun BaseExpandableText(
showScroll: Boolean = false,
onTextMeasured: (TextLayoutResult) -> Unit = {},
onTap: (() -> Unit)? = null
-){
+) {
var textModifier: Modifier = Modifier
- if(qaTag != null) textModifier = textModifier.qaTag(qaTag)
- if(expanded) textModifier = textModifier.height(expandedMaxHeight)
- if(showScroll){
+ if (qaTag != null) textModifier = textModifier.qaTag(qaTag)
+ if (expanded) textModifier = textModifier.height(expandedMaxHeight)
+ if (showScroll) {
val scrollState = rememberScrollState()
val scrollEdge = LocalDimensions.current.xxxsSpacing
val scrollWidth = 2.dp
@@ -871,7 +1176,7 @@ fun BaseExpandableText(
Column(
modifier = modifier.then(
- if(onTap != null) Modifier.clickable { onTap() } else Modifier
+ if (onTap != null) Modifier.clickable { onTap() } else Modifier
),
horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -888,7 +1193,7 @@ fun BaseExpandableText(
overflow = if (expanded) TextOverflow.Clip else TextOverflow.Ellipsis
)
- if(showButton) {
+ if (showButton) {
Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing))
Text(
text = if (expanded) collapseButtonText else expandButtonText,
@@ -1018,9 +1323,10 @@ fun ActionRowItem(
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing),
endContent: @Composable (() -> Unit)? = null
-){
+) {
Row(
- modifier = modifier.heightIn(min = minHeight)
+ modifier = modifier
+ .heightIn(min = minHeight)
.clickable { onClick() }
.padding(paddingValues)
.qaTag(qaTag),
@@ -1074,7 +1380,7 @@ fun IconActionRowItem(
iconSize: Dp = LocalDimensions.current.iconMedium,
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing),
-){
+) {
ActionRowItem(
modifier = modifier,
title = title,
@@ -1092,7 +1398,8 @@ fun IconActionRowItem(
modifier = Modifier.size(LocalDimensions.current.itemButtonIconSpacing)
) {
Icon(
- modifier = Modifier.align(Alignment.Center)
+ modifier = Modifier
+ .align(Alignment.Center)
.size(iconSize)
.qaTag(R.string.qa_action_item_icon),
painter = painterResource(id = icon),
@@ -1118,7 +1425,7 @@ fun SwitchActionRowItem(
subtitleStyle: TextStyle = LocalType.current.small,
paddingValues: PaddingValues = PaddingValues(horizontal = LocalDimensions.current.smallSpacing),
minHeight: Dp = LocalDimensions.current.minItemButtonHeight,
-){
+) {
ActionRowItem(
modifier = modifier,
title = title,
@@ -1142,7 +1449,7 @@ fun SwitchActionRowItem(
@Preview
@Composable
-fun PreviewActionRowItems(){
+fun PreviewActionRowItems() {
PreviewTheme {
Column(
modifier = Modifier.padding(20.dp),
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt
index ef9215cace..d54ceceb30 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/Button.kt
@@ -73,7 +73,8 @@ fun Button(
style.applyButtonConstraints {
androidx.compose.material3.Button(
onClick = onClick,
- modifier = modifier.heightIn(min = style.minHeight)
+ modifier = modifier
+ .heightIn(min = style.minHeight)
.defaultMinSize(minWidth = minWidth),
enabled = enabled,
interactionSource = interactionSource,
@@ -352,6 +353,27 @@ fun BorderlessHtmlButton(
}
}
+@Composable
+fun SlimFillButtonRect(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = LocalColors.current.accent,
+ enabled: Boolean = true,
+ minWidth: Dp = 0.dp,
+ onClick: () -> Unit
+) {
+ Button(
+ text, onClick,
+ ButtonType.Fill(color),
+ modifier,
+ enabled,
+ style = ButtonStyle.Slim,
+ shape = sessionShapes().extraSmall,
+ minWidth = minWidth
+ )
+}
+
+
val MutableInteractionSource.releases
get() = interactions.filter { it is PressInteraction.Release }
@@ -380,6 +402,7 @@ private fun VariousButtons(
SlimOutlineButton("Slim Outline Disabled", enabled = false) {}
SlimAccentOutlineButton("Slim Accent") {}
SlimOutlineButton("Slim Danger", color = LocalColors.current.danger) {}
+ SlimFillButtonRect("Slim Fill", color = LocalColors.current.accent) {}
BorderlessButton("Borderless Button") {}
BorderlessButton("Borderless Secondary", color = LocalColors.current.textSecondary) {}
FillButtonRect("Fill Rect") {}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt
index bd4a855fb7..e0b67fa2ad 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/ButtonStyle.kt
@@ -52,7 +52,7 @@ interface ButtonStyle {
object Slim: ButtonStyle {
@Composable
- override fun textStyle() = LocalType.current.extraSmall.bold()
+ override fun textStyle() = LocalType.current.small.bold()
.copy(textAlign = TextAlign.Center)
override val minHeight = 29.dp
}
diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml
index dcd853dd0c..a727f999e5 100644
--- a/content-descriptions/src/main/res/values/strings.xml
+++ b/content-descriptions/src/main/res/values/strings.xml
@@ -291,4 +291,6 @@
action-item-icon
qa-blocked-contacts-settings-item
+ qa-collapsing-footer-action
+
\ No newline at end of file