Фикс: Новый флоу создания групп

This commit is contained in:
2026-04-07 17:16:24 +05:00
parent 19508090a5
commit 43422bb131
2 changed files with 395 additions and 34 deletions

View File

@@ -1628,6 +1628,7 @@ fun MainScreen(
accountName = accountName,
accountUsername = accountUsername,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
onGroupOpened = { groupUser ->
navStack =

View File

@@ -24,6 +24,10 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
@@ -73,9 +77,13 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.DialogDao
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
@@ -93,6 +101,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private enum class GroupSetupStep {
MEMBERS,
DETAILS,
DESCRIPTION
}
@@ -106,6 +115,7 @@ fun GroupSetupScreen(
accountName: String,
accountUsername: String,
avatarRepository: AvatarRepository? = null,
dialogDao: DialogDao? = null,
onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit
) {
@@ -116,9 +126,53 @@ fun GroupSetupScreen(
val keyboardController = LocalSoftwareKeyboardController.current
val nameFocusRequester = remember { FocusRequester() }
var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
var step by rememberSaveable { mutableStateOf(GroupSetupStep.MEMBERS) }
var title by rememberSaveable { mutableStateOf("") }
var description by rememberSaveable { mutableStateOf("") }
// Members selection state
var memberSearchQuery by rememberSaveable { mutableStateOf("") }
var selectedMembers by remember { mutableStateOf<List<SearchUser>>(emptyList()) }
var allContacts by remember { mutableStateOf<List<SearchUser>>(emptyList()) }
val memberSearchFocusRequester = remember { FocusRequester() }
// Load contacts from database
val resolvedDialogDao = dialogDao ?: remember(context) {
RosettaDatabase.getDatabase(context).dialogDao()
}
LaunchedEffect(accountPublicKey) {
withContext(Dispatchers.IO) {
val dialogs = resolvedDialogDao.getDialogsPaged(accountPublicKey, limit = 200, offset = 0)
allContacts = dialogs
.filter { dialog ->
val key = dialog.opponentKey.lowercase()
!key.startsWith("#group:") && !key.startsWith("group:") &&
dialog.opponentKey != accountPublicKey &&
dialog.opponentKey != "0x000000000000000000000000000000000000000001" &&
dialog.opponentKey != "0x000000000000000000000000000000000000000002"
}
.map { dialog ->
SearchUser(
publicKey = dialog.opponentKey,
title = dialog.opponentTitle,
username = dialog.opponentUsername,
verified = dialog.verified,
online = dialog.isOnline
)
}
}
}
val filteredContacts = remember(allContacts, memberSearchQuery) {
if (memberSearchQuery.isBlank()) allContacts
else {
val q = memberSearchQuery.lowercase()
allContacts.filter { user ->
user.title.lowercase().contains(q) ||
user.username.lowercase().contains(q)
}
}
}
var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
var isLoading by rememberSaveable { mutableStateOf(false) }
var errorText by rememberSaveable { mutableStateOf<String?>(null) }
@@ -153,7 +207,7 @@ fun GroupSetupScreen(
val disabledActionColor = if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFFC7C7CC)
val disabledActionContentColor = if (isDarkTheme) Color(0xFFAAAAAF) else Color(0xFF8E8E93)
val groupAvatarCameraButtonColor =
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
if (isDarkTheme) sectionColor else accentColor
val groupAvatarCameraIconColor =
if (isDarkTheme) accentColor else Color.White
@@ -233,11 +287,13 @@ fun GroupSetupScreen(
fun handleBack() {
if (isLoading) return
errorText = null
if (step == GroupSetupStep.DESCRIPTION) {
step = GroupSetupStep.DETAILS
} else {
dismissInputUi()
onBack()
when (step) {
GroupSetupStep.DESCRIPTION -> step = GroupSetupStep.DETAILS
GroupSetupStep.DETAILS -> step = GroupSetupStep.MEMBERS
GroupSetupStep.MEMBERS -> {
dismissInputUi()
onBack()
}
}
}
@@ -245,7 +301,11 @@ fun GroupSetupScreen(
val canGoNext = title.trim().isNotEmpty()
val canCreate = canGoNext && !isLoading
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
val actionEnabled = when (step) {
GroupSetupStep.MEMBERS -> true
GroupSetupStep.DETAILS -> canGoNext
GroupSetupStep.DESCRIPTION -> canCreate
}
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val imeBottomDp = with(density) { imeBottomPx.toDp() }
@@ -256,6 +316,16 @@ fun GroupSetupScreen(
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
}
if (step == GroupSetupStep.MEMBERS) {
delay(120)
memberSearchFocusRequester.requestFocus()
keyboardController?.show()
}
if (step == GroupSetupStep.DETAILS) {
delay(120)
nameFocusRequester.requestFocus()
keyboardController?.show()
}
}
LaunchedEffect(Unit) {
@@ -339,7 +409,11 @@ fun GroupSetupScreen(
}
LaunchedEffect(Unit) {
if (step == GroupSetupStep.DETAILS) {
if (step == GroupSetupStep.MEMBERS) {
delay(120)
memberSearchFocusRequester.requestFocus()
keyboardController?.show()
} else if (step == GroupSetupStep.DETAILS) {
delay(120)
nameFocusRequester.requestFocus()
keyboardController?.show()
@@ -351,12 +425,27 @@ fun GroupSetupScreen(
topBar = {
TopAppBar(
title = {
Text(
text = if (step == GroupSetupStep.DETAILS) "New Group" else "Group Description",
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 20.sp
)
Column {
Text(
text = when (step) {
GroupSetupStep.MEMBERS -> "New Group"
GroupSetupStep.DETAILS -> "New Group"
GroupSetupStep.DESCRIPTION -> "Group Description"
},
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 20.sp
)
if (step == GroupSetupStep.MEMBERS) {
val countText = if (selectedMembers.isEmpty()) "up to 200 members"
else "${selectedMembers.size} of 200 selected"
Text(
text = countText,
color = Color.White.copy(alpha = 0.7f),
fontSize = 13.sp
)
}
}
},
navigationIcon = {
IconButton(onClick = ::handleBack) {
@@ -404,10 +493,196 @@ fun GroupSetupScreen(
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.navigationBarsPadding()
) {
if (step == GroupSetupStep.DETAILS) {
if (step == GroupSetupStep.MEMBERS) {
// Search bar
TextField(
value = memberSearchQuery,
onValueChange = { memberSearchQuery = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(memberSearchFocusRequester),
placeholder = {
Text(
text = "Who would you like to add?",
color = secondaryTextColor,
fontSize = 15.sp
)
},
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = primaryTextColor,
fontSize = 15.sp
),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedTextColor = primaryTextColor,
unfocusedTextColor = primaryTextColor,
focusedContainerColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White,
unfocusedContainerColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
cursorColor = accentColor
)
)
// Selected members chips
if (selectedMembers.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(selectedMembers, key = { it.publicKey }) { user ->
val chipName = user.title.ifEmpty { user.username.ifEmpty { user.publicKey.take(8) } }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(60.dp)
.clickable {
selectedMembers = selectedMembers.filter { it.publicKey != user.publicKey }
}
) {
Box(contentAlignment = Alignment.BottomEnd) {
AvatarImage(
publicKey = user.publicKey,
avatarRepository = avatarRepository,
size = 46.dp,
isDarkTheme = isDarkTheme,
displayName = user.title.ifEmpty { user.username }
)
Box(
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(backgroundColor)
.padding(1.dp)
.clip(CircleShape)
.background(secondaryTextColor.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Close,
contentDescription = "Remove",
tint = Color.White,
modifier = Modifier.size(10.dp)
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = chipName,
fontSize = 11.sp,
color = primaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center
)
}
}
}
}
// Divider
Box(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(secondaryTextColor.copy(alpha = 0.2f))
)
// Contact list
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
LaunchedEffect(listState.isScrollInProgress) {
if (listState.isScrollInProgress) {
focusManager.clearFocus()
keyboardController?.hide()
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
items(filteredContacts, key = { it.publicKey }) { user ->
val isSelected = selectedMembers.any { it.publicKey == user.publicKey }
val displayName = user.title.ifEmpty {
user.username.ifEmpty { user.publicKey.take(8) + "..." }
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedMembers = if (isSelected) {
selectedMembers.filter { it.publicKey != user.publicKey }
} else {
selectedMembers + user
}
}
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = user.publicKey,
avatarRepository = avatarRepository,
size = 42.dp,
isDarkTheme = isDarkTheme,
showOnlineIndicator = false,
isOnline = false,
displayName = user.title.ifEmpty { user.username }
)
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = primaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (user.verified != 0) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = user.verified,
size = 16
)
}
}
if (user.username.isNotEmpty()) {
Text(
text = "@${user.username}",
fontSize = 14.sp,
color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
// Checkmark
if (isSelected) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
.background(accentColor),
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Selected",
tint = Color.White,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
} else if (step == GroupSetupStep.DETAILS) {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(16.dp))
Row(
@@ -520,34 +795,33 @@ fun GroupSetupScreen(
Spacer(modifier = Modifier.height(22.dp))
val totalMembers = 1 + selectedMembers.size
Text(
text = "1 member",
text = "$totalMembers member${if (totalMembers > 1) "s" else ""}",
color = accentColor,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
modifier = Modifier.padding(bottom = 0.dp)
modifier = Modifier.padding(start = 0.dp, bottom = 0.dp)
)
Spacer(modifier = Modifier.height(8.dp))
// Self (admin)
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(sectionColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
size = 50.dp,
size = 42.dp,
isDarkTheme = isDarkTheme,
displayName = selfTitle
)
Spacer(modifier = Modifier.size(12.dp))
Spacer(modifier = Modifier.size(14.dp))
Column(
modifier = Modifier.weight(1f),
@@ -581,14 +855,61 @@ fun GroupSetupScreen(
}
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "After creating the group, you can invite as many users as you need.",
color = secondaryTextColor,
fontSize = 13.sp,
lineHeight = 18.sp
)
// Selected members
selectedMembers.forEach { member ->
val memberName = member.title.ifEmpty {
member.username.ifEmpty { member.publicKey.take(8) + "..." }
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 0.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = member.publicKey,
avatarRepository = avatarRepository,
size = 42.dp,
isDarkTheme = isDarkTheme,
displayName = member.title.ifEmpty { member.username }
)
Spacer(modifier = Modifier.size(14.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = memberName,
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (member.verified != 0) {
VerifiedBadge(verified = member.verified, size = 16)
}
}
if (member.username.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "@${member.username}",
color = secondaryTextColor,
fontSize = 13.sp
)
}
}
}
}
} // end DETAILS Column
} else {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(16.dp))
Row(
@@ -697,6 +1018,7 @@ fun GroupSetupScreen(
fontSize = 13.sp,
textAlign = TextAlign.Start
)
} // end DESCRIPTION Column
}
if (!errorText.isNullOrBlank()) {
@@ -712,6 +1034,13 @@ fun GroupSetupScreen(
FloatingActionButton(
elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
onClick = {
if (step == GroupSetupStep.MEMBERS) {
dismissInputUi()
memberSearchQuery = ""
step = GroupSetupStep.DETAILS
return@FloatingActionButton
}
if (step == GroupSetupStep.DETAILS) {
if (canGoNext) {
errorText = null
@@ -736,6 +1065,37 @@ fun GroupSetupScreen(
avatarUriString = selectedAvatarUri
)
}
// Send invite to all selected members
if (selectedMembers.isNotEmpty()) {
withContext(Dispatchers.IO) {
val groupRepo = GroupRepository.getInstance(context)
val groupKey = groupRepo.getGroupKey(
accountPublicKey, accountPrivateKey,
result.dialogPublicKey
)
if (!groupKey.isNullOrBlank()) {
val invite = groupRepo.constructInviteString(
groupId = result.dialogPublicKey,
title = result.title.ifBlank { title.trim() },
encryptKey = groupKey,
description = description.trim()
)
if (invite.isNotBlank()) {
val msgRepo = MessageRepository.getInstance(context)
selectedMembers.forEach { member ->
runCatching {
msgRepo.sendMessage(
toPublicKey = member.publicKey,
text = invite
)
}
}
}
}
}
}
dismissInputUi()
openGroup(
dialogPublicKey = result.dialogPublicKey,