diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 4dd5c36..c956b73 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 20bb7d0..9c78bef 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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>(emptyList()) } + var allContacts by remember { mutableStateOf>(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(null) } var isLoading by rememberSaveable { mutableStateOf(false) } var errorText by rememberSaveable { mutableStateOf(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,