Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d090e40
Initial component
jbsession Oct 24, 2025
3762010
fix crash
jbsession Oct 24, 2025
155b89a
Slim fill button
jbsession Oct 24, 2025
081e0fc
fixed bg
jbsession Oct 26, 2025
6a9e31f
collapse ui state
jbsession Oct 28, 2025
dfa5b36
Cleanup and renaming
jbsession Oct 28, 2025
e14c297
cleanup
jbsession Oct 28, 2025
d70a0da
Fixed some animations
jbsession Oct 28, 2025
c7a063a
Merge remote-tracking branch 'upstream/dev' into features/collapsing-…
jbsession Oct 28, 2025
cdb21da
String from crowdin
jbsession Oct 28, 2025
6b90a93
remoed qa placeholder
jbsession Oct 28, 2025
f919206
updated behavior
jbsession Oct 28, 2025
b7c497d
Fixed rules for bottomsheet
jbsession Oct 28, 2025
ccc20e3
Slim button textStyle changed to
jbsession Oct 28, 2025
9f0faa8
Expose minWidth for slim fill button
jbsession Oct 28, 2025
e666367
Silde in and out duration changes for collapsible footer
jbsession Oct 28, 2025
372b2b7
Updated collapse animation
jbsession Oct 28, 2025
5511515
Fixed modifier assignment, slimfill min width set to 0dp
jbsession Oct 28, 2025
29181f8
Fixed spacings
jbsession Oct 29, 2025
9135f59
Updated state for Collapsible footer
jbsession Oct 29, 2025
ee5394e
Remove fadingBox
jbsession Oct 29, 2025
c7949ca
Removed rotation for close button
jbsession Oct 29, 2025
a85e310
Initial animation fix
jbsession Oct 29, 2025
a12c219
Fixed animation
jbsession Oct 29, 2025
e1acce6
Fixed button mod
jbsession Oct 29, 2025
f9624d9
inline modifier
jbsession Oct 29, 2025
5a9a6af
animation tweaks
ThomasSession Oct 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,18 +17,22 @@ 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
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

Expand All @@ -39,6 +45,7 @@ open class SelectContactsViewModel @AssistedInject constructor(
@Assisted private val excludingAccountIDs: Set<Address>,
@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("")
Expand Down Expand Up @@ -71,6 +78,27 @@ open class SelectContactsViewModel @AssistedInject constructor(
val currentSelected: Set<Address>
get() = mutableSelectedContactAccountIDs.value

private val footerCollapsed = MutableStateFlow(false)

val collapsibleFooterState: StateFlow<CollapsibleFooterState> =
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<Any>)
.debounce(100L)
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,40 @@ 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
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
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
Expand All @@ -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

)
}

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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)
)
}
}
}

}
}

Expand Down Expand Up @@ -174,6 +205,13 @@ private fun PreviewSelectContacts() {
onSearchQueryClear = {},
onDoneClicked = {},
onBack = {},
data = SelectContactsViewModel.CollapsibleFooterState(
collapsed = false,
visible = true,
footerActionTitle = GetString("1 Contact Selected")
),
onToggleFooter = { },
onCloseFooter = { },
)
}
}
Expand All @@ -192,7 +230,13 @@ private fun PreviewSelectEmptyContacts() {
onSearchQueryClear = {},
onDoneClicked = {},
onBack = {},
banner = { GroupMinimumVersionBanner() }
data = SelectContactsViewModel.CollapsibleFooterState(
collapsed = true,
visible = false,
footerActionTitle = GetString("")
),
onToggleFooter = { },
onCloseFooter = { }
)
}
}
Expand All @@ -211,7 +255,13 @@ private fun PreviewSelectEmptyContactsWithSearch() {
onSearchQueryClear = {},
onDoneClicked = {},
onBack = {},
banner = { GroupMinimumVersionBanner() }
data = SelectContactsViewModel.CollapsibleFooterState(
collapsed = true,
visible = false,
footerActionTitle = GetString("")
),
onToggleFooter = { },
onCloseFooter = { }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class BlockedContactsViewModel @Inject constructor(
avatarUtils = avatarUtils,
proStatusManager = proStatusManager,
recipientRepository = recipientRepository,
context = context
) {
private val _unblockDialog = MutableStateFlow(false)
val unblockDialog: StateFlow<Boolean> = _unblockDialog
Expand Down
Loading