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