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

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, accountName = accountName,
accountUsername = accountUsername, accountUsername = accountUsername,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } }, onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
onGroupOpened = { groupUser -> onGroupOpened = { groupUser ->
navStack = navStack =

View File

@@ -24,6 +24,10 @@ import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -73,9 +77,13 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.GroupRepository 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.GroupStatus
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository 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.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
@@ -93,6 +101,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private enum class GroupSetupStep { private enum class GroupSetupStep {
MEMBERS,
DETAILS, DETAILS,
DESCRIPTION DESCRIPTION
} }
@@ -106,6 +115,7 @@ fun GroupSetupScreen(
accountName: String, accountName: String,
accountUsername: String, accountUsername: String,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
dialogDao: DialogDao? = null,
onBack: () -> Unit, onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit onGroupOpened: (SearchUser) -> Unit
) { ) {
@@ -116,9 +126,53 @@ fun GroupSetupScreen(
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val nameFocusRequester = remember { FocusRequester() } val nameFocusRequester = remember { FocusRequester() }
var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) } var step by rememberSaveable { mutableStateOf(GroupSetupStep.MEMBERS) }
var title by rememberSaveable { mutableStateOf("") } var title by rememberSaveable { mutableStateOf("") }
var description 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 selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
var isLoading by rememberSaveable { mutableStateOf(false) } var isLoading by rememberSaveable { mutableStateOf(false) }
var errorText by rememberSaveable { mutableStateOf<String?>(null) } var errorText by rememberSaveable { mutableStateOf<String?>(null) }
@@ -153,7 +207,7 @@ fun GroupSetupScreen(
val disabledActionColor = if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFFC7C7CC) val disabledActionColor = if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFFC7C7CC)
val disabledActionContentColor = if (isDarkTheme) Color(0xFFAAAAAF) else Color(0xFF8E8E93) val disabledActionContentColor = if (isDarkTheme) Color(0xFFAAAAAF) else Color(0xFF8E8E93)
val groupAvatarCameraButtonColor = val groupAvatarCameraButtonColor =
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6) if (isDarkTheme) sectionColor else accentColor
val groupAvatarCameraIconColor = val groupAvatarCameraIconColor =
if (isDarkTheme) accentColor else Color.White if (isDarkTheme) accentColor else Color.White
@@ -233,11 +287,13 @@ fun GroupSetupScreen(
fun handleBack() { fun handleBack() {
if (isLoading) return if (isLoading) return
errorText = null errorText = null
if (step == GroupSetupStep.DESCRIPTION) { when (step) {
step = GroupSetupStep.DETAILS GroupSetupStep.DESCRIPTION -> step = GroupSetupStep.DETAILS
} else { GroupSetupStep.DETAILS -> step = GroupSetupStep.MEMBERS
dismissInputUi() GroupSetupStep.MEMBERS -> {
onBack() dismissInputUi()
onBack()
}
} }
} }
@@ -245,7 +301,11 @@ fun GroupSetupScreen(
val canGoNext = title.trim().isNotEmpty() val canGoNext = title.trim().isNotEmpty()
val canCreate = canGoNext && !isLoading 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 density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density) val imeBottomPx = WindowInsets.ime.getBottom(density)
val imeBottomDp = with(density) { imeBottomPx.toDp() } val imeBottomDp = with(density) { imeBottomPx.toDp() }
@@ -256,6 +316,16 @@ fun GroupSetupScreen(
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false }) 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) { LaunchedEffect(Unit) {
@@ -339,7 +409,11 @@ fun GroupSetupScreen(
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (step == GroupSetupStep.DETAILS) { if (step == GroupSetupStep.MEMBERS) {
delay(120)
memberSearchFocusRequester.requestFocus()
keyboardController?.show()
} else if (step == GroupSetupStep.DETAILS) {
delay(120) delay(120)
nameFocusRequester.requestFocus() nameFocusRequester.requestFocus()
keyboardController?.show() keyboardController?.show()
@@ -351,12 +425,27 @@ fun GroupSetupScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( Column {
text = if (step == GroupSetupStep.DETAILS) "New Group" else "Group Description", Text(
fontWeight = FontWeight.SemiBold, text = when (step) {
color = Color.White, GroupSetupStep.MEMBERS -> "New Group"
fontSize = 20.sp 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 = { navigationIcon = {
IconButton(onClick = ::handleBack) { IconButton(onClick = ::handleBack) {
@@ -404,10 +493,196 @@ fun GroupSetupScreen(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(horizontal = 16.dp)
.navigationBarsPadding() .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)) Spacer(modifier = Modifier.height(16.dp))
Row( Row(
@@ -520,34 +795,33 @@ fun GroupSetupScreen(
Spacer(modifier = Modifier.height(22.dp)) Spacer(modifier = Modifier.height(22.dp))
val totalMembers = 1 + selectedMembers.size
Text( Text(
text = "1 member", text = "$totalMembers member${if (totalMembers > 1) "s" else ""}",
color = accentColor, color = accentColor,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 15.sp, fontSize = 15.sp,
modifier = Modifier.padding(bottom = 0.dp) modifier = Modifier.padding(start = 0.dp, bottom = 0.dp)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Self (admin)
Row( Row(
modifier = modifier = Modifier
Modifier .fillMaxWidth()
.fillMaxWidth() .padding(horizontal = 0.dp, vertical = 8.dp),
.clip(RoundedCornerShape(14.dp))
.background(sectionColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AvatarImage( AvatarImage(
publicKey = accountPublicKey, publicKey = accountPublicKey,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 50.dp, size = 42.dp,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
displayName = selfTitle displayName = selfTitle
) )
Spacer(modifier = Modifier.size(12.dp)) Spacer(modifier = Modifier.size(14.dp))
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -581,14 +855,61 @@ fun GroupSetupScreen(
} }
} }
Spacer(modifier = Modifier.height(10.dp)) // Selected members
Text( selectedMembers.forEach { member ->
text = "After creating the group, you can invite as many users as you need.", val memberName = member.title.ifEmpty {
color = secondaryTextColor, member.username.ifEmpty { member.publicKey.take(8) + "..." }
fontSize = 13.sp, }
lineHeight = 18.sp 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 { } else {
Column(modifier = Modifier.padding(horizontal = 16.dp)) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Row( Row(
@@ -697,6 +1018,7 @@ fun GroupSetupScreen(
fontSize = 13.sp, fontSize = 13.sp,
textAlign = TextAlign.Start textAlign = TextAlign.Start
) )
} // end DESCRIPTION Column
} }
if (!errorText.isNullOrBlank()) { if (!errorText.isNullOrBlank()) {
@@ -712,6 +1034,13 @@ fun GroupSetupScreen(
FloatingActionButton( FloatingActionButton(
elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp), elevation = androidx.compose.material3.FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
onClick = { onClick = {
if (step == GroupSetupStep.MEMBERS) {
dismissInputUi()
memberSearchQuery = ""
step = GroupSetupStep.DETAILS
return@FloatingActionButton
}
if (step == GroupSetupStep.DETAILS) { if (step == GroupSetupStep.DETAILS) {
if (canGoNext) { if (canGoNext) {
errorText = null errorText = null
@@ -736,6 +1065,37 @@ fun GroupSetupScreen(
avatarUriString = selectedAvatarUri 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() dismissInputUi()
openGroup( openGroup(
dialogPublicKey = result.dialogPublicKey, dialogPublicKey = result.dialogPublicKey,