Create group v2 - Implement navigation to group details screen.

This commit is contained in:
jeffrey-signal
2025-10-23 10:34:10 -04:00
parent 9d545412a5
commit 19558c5325
6 changed files with 142 additions and 31 deletions

View File

@@ -159,7 +159,7 @@ public class CreateGroupActivity extends ContactSelectionActivity implements Con
@Override
public void onSelectionChanged() {
int selectedMembers = contactsFragment.getSelectedMembersSize();
int selectedMembers = contactsFragment.getSelectedContactsCount();
int selectedContactsCount = contactsFragment.getTotalMemberCount();
if (selectedContactsCount == 0) {
getToolbar().setTitle(getString(R.string.CreateGroupActivity__select_members));

View File

@@ -5,13 +5,16 @@
package org.thoughtcrime.securesms.groups.ui.creategroup
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResult
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
@@ -31,11 +34,13 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -55,7 +60,9 @@ import org.thoughtcrime.securesms.contacts.SelectedContact
import org.thoughtcrime.securesms.conversation.RecipientPicker
import org.thoughtcrime.securesms.conversation.RecipientPickerCallbacks
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.NavTarget
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage
import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity
import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.findby.FindByActivity
@@ -85,7 +92,10 @@ class CreateGroupActivityV2 : PassphraseRequiredActivity() {
setContent {
SignalTheme {
CreateGroupScreen(
closeScreen = navigateBack
closeScreen = { resultCode ->
resultCode?.let(::setResult)
navigateBack()
}
)
}
}
@@ -95,13 +105,22 @@ class CreateGroupActivityV2 : PassphraseRequiredActivity() {
@Composable
private fun CreateGroupScreen(
viewModel: CreateGroupViewModel = viewModel { CreateGroupViewModel() },
closeScreen: () -> Unit
closeScreen: (resultCode: Int?) -> Unit
) {
val findByLauncher: ActivityResultLauncher<FindByMode> = rememberLauncherForActivityResult(
contract = FindByActivity.Contract(),
onResult = { id -> id?.let(viewModel::selectRecipient) }
)
val addDetailsLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
closeScreen(Activity.RESULT_OK)
}
}
)
val callbacks = remember {
object : UiCallbacks {
override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
@@ -112,11 +131,25 @@ private fun CreateGroupScreen(
override fun onPendingRecipientSelectionsConsumed() = viewModel.clearPendingRecipientSelections()
override fun onNextClicked(): Unit = viewModel.continueToGroupDetails()
override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage()
override fun onBackPressed() = closeScreen()
override fun onPendingDestinationConsumed() = viewModel.clearPendingDestination()
override fun onBackPressed() = closeScreen(null)
}
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(uiState.pendingDestination) {
when (val pendingDestination = uiState.pendingDestination) {
is NavTarget.AddGroupDetails -> {
addDetailsLauncher.launch(AddGroupDetailsActivity.newIntent(context, pendingDestination.recipientIds))
callbacks.onPendingDestinationConsumed()
}
null -> Unit
}
}
CreateGroupScreenUi(
uiState = uiState,
callbacks = callbacks
@@ -265,6 +298,7 @@ private interface UiCallbacks :
fun onNextClicked()
fun onUserMessageDismissed(userMessage: UserMessage)
fun onBackPressed()
fun onPendingDestinationConsumed()
object Empty : UiCallbacks {
override fun onSearchQueryChanged(query: String) = Unit
@@ -275,6 +309,7 @@ private interface UiCallbacks :
override fun onNextClicked() = Unit
override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
override fun onBackPressed() = Unit
override fun onPendingDestinationConsumed() = Unit
}
}
@@ -283,6 +318,7 @@ private fun UserMessagesHost(
userMessage: UserMessage?,
onDismiss: (UserMessage) -> Unit
) {
val context: Context = LocalContext.current
when (userMessage) {
null -> {}
@@ -297,6 +333,16 @@ private fun UserMessagesHost(
dismiss = stringResource(android.R.string.ok),
onDismiss = { onDismiss(userMessage) }
)
is UserMessage.Info.RecipientsNotSignalUsers -> Dialogs.SimpleMessageDialog(
message = pluralStringResource(
id = R.plurals.CreateGroupActivity_not_signal_users,
count = userMessage.recipients.size,
userMessage.recipients.joinToString(", ") { it.getDisplayName(context) }
),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onDismiss(userMessage) }
)
}
}

View File

@@ -6,22 +6,36 @@
package org.thoughtcrime.securesms.groups.ui.creategroup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.contacts.SelectedContact
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.NavTarget
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage.Info
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.PhoneNumber
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientRepository
import org.thoughtcrime.securesms.util.RemoteConfig
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
class CreateGroupViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(CreateGroupViewModel::class)
}
private val internalUiState = MutableStateFlow(CreateGroupUiState())
val uiState: StateFlow<CreateGroupUiState> = internalUiState.asStateFlow()
@@ -91,12 +105,66 @@ class CreateGroupViewModel : ViewModel() {
}
fun continueToGroupDetails() {
// TODO [jeff] pass selected recipients to AddGroupDetailsActivity
viewModelScope.launch {
val stopwatch = Stopwatch(title = "Recipient Refresh")
internalUiState.update { it.copy(isLookingUpRecipient = true) }
val selectedRecipients = uiState.value.newSelections.asRecipients(stopwatch)
stopwatch.split(label = "registered")
stopwatch.stop(tag = TAG)
val notSignalUsers = selectedRecipients.filter { !it.isRegistered || !it.hasServiceId }
if (notSignalUsers.isNotEmpty()) {
internalUiState.update {
it.copy(
isLookingUpRecipient = false,
userMessage = Info.RecipientsNotSignalUsers(recipients = notSignalUsers)
)
}
} else {
internalUiState.update {
it.copy(
isLookingUpRecipient = false,
pendingDestination = NavTarget.AddGroupDetails(recipientIds = selectedRecipients.map(Recipient::id))
)
}
}
}
}
private fun List<SelectedContact>.asRecipients(stopwatch: Stopwatch): List<Recipient> {
val selectedRecipientIds: List<RecipientId> = this.map { it.orCreateRecipientId }
val recipientsNeedingRegistrationCheck = Recipient
.resolvedList(selectedRecipientIds)
.also { stopwatch.split(label = "resolve") }
.filter { !it.isRegistered || !it.hasServiceId }
.toSet()
Log.d(TAG, "Need to do ${recipientsNeedingRegistrationCheck.size} registration checks.")
recipientsNeedingRegistrationCheck.forEach { recipient ->
try {
ContactDiscovery.refresh(
context = AppDependencies.application,
recipient = recipient,
notifyOfNewUsers = false,
timeoutMs = 10.seconds.inWholeMilliseconds
)
} catch (e: IOException) {
Log.w(TAG, "Failed to refresh registered status for ${recipient.id}", e)
}
}
return Recipient.resolvedList(selectedRecipientIds)
}
fun clearUserMessage() {
internalUiState.update { it.copy(userMessage = null) }
}
fun clearPendingDestination() {
internalUiState.update { it.copy(pendingDestination = null) }
}
}
data class CreateGroupUiState(
@@ -107,12 +175,18 @@ data class CreateGroupUiState(
val totalMembersCount: Int = 0,
val isLookingUpRecipient: Boolean = false,
val pendingRecipientSelections: Set<RecipientId> = emptySet(),
val pendingDestination: NavTarget? = null,
val userMessage: UserMessage? = null
) {
sealed interface UserMessage {
sealed interface Info : UserMessage {
data class RecipientNotSignalUser(val phone: PhoneNumber) : Info
data class RecipientsNotSignalUsers(val recipients: List<Recipient>) : Info
data object NetworkError : Info
}
}
sealed interface NavTarget {
data class AddGroupDetails(val recipientIds: List<RecipientId>) : NavTarget
}
}