diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 4bfefc1..1177004 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -866,7 +866,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Очищаем reply ПОСЛЕ добавления сообщения в список с небольшой задержкой viewModelScope.launch { - kotlinx.coroutines.delay(100) + kotlinx.coroutines.delay(300) clearReplyMessages() } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 538173c..4ebad1d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -18,11 +18,14 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -203,6 +206,11 @@ fun ChatsListScreen( // 🔥 Используем rememberSaveable чтобы сохранить состояние при навигации // Header сразу visible = true, без анимации при возврате из чата var visible by rememberSaveable { mutableStateOf(true) } + + // Confirmation dialogs state + var dialogToDelete by remember { mutableStateOf(null) } + var dialogToBlock by remember { mutableStateOf(null) } + var dialogToUnblock by remember { mutableStateOf(null) } // Dev console dialog - commented out for now /* @@ -509,13 +517,31 @@ fun ChatsListScreen( // Show dialogs list LazyColumn(modifier = Modifier.fillMaxSize()) { items(dialogsList, key = { it.opponentKey }) { dialog -> - DialogItem( + val isSavedMessages = dialog.opponentKey == accountPublicKey + // Check if user is blocked + var isBlocked by remember { mutableStateOf(false) } + LaunchedEffect(dialog.opponentKey) { + isBlocked = chatsViewModel.isUserBlocked(dialog.opponentKey) + } + + SwipeableDialogItem( dialog = dialog, isDarkTheme = isDarkTheme, isTyping = typingUsers.contains(dialog.opponentKey), + isBlocked = isBlocked, + isSavedMessages = isSavedMessages, onClick = { val user = chatsViewModel.dialogToSearchUser(dialog) onUserSelect(user) + }, + onDelete = { + dialogToDelete = dialog + }, + onBlock = { + dialogToBlock = dialog + }, + onUnblock = { + dialogToUnblock = dialog } ) } @@ -526,6 +552,170 @@ fun ChatsListScreen( } } } // Close ModalNavigationDrawer + + // 🔥 Confirmation Dialogs + + // Delete Dialog Confirmation + dialogToDelete?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToDelete = null }, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = "Delete conversation?", + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + }, + text = { + val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + Text( + text = "All messages with $displayName will be permanently deleted. This action cannot be undone.", + fontSize = 15.sp, + color = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF666666) + ) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + chatsViewModel.deleteDialog(dialog.opponentKey) + dialogToDelete = null + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = Color(0xFFFF3B30) + ) + ) { + Text("Delete", fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton( + onClick = { dialogToDelete = null } + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + shape = RoundedCornerShape(16.dp) + ) + } + + // Block Dialog Confirmation + dialogToBlock?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToBlock = null }, + icon = { + Icon( + imageVector = Icons.Default.Block, + contentDescription = null, + tint = Color(0xFFFF6B6B), + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = "Block user?", + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + }, + text = { + val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + Text( + text = "$displayName will no longer be able to send you messages. You can unblock them later.", + fontSize = 15.sp, + color = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF666666) + ) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + chatsViewModel.blockUser(dialog.opponentKey) + dialogToBlock = null + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = Color(0xFFFF3B30) + ) + ) { + Text("Block", fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton( + onClick = { dialogToBlock = null } + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + shape = RoundedCornerShape(16.dp) + ) + } + + // Unblock Dialog Confirmation + dialogToUnblock?.let { dialog -> + AlertDialog( + onDismissRequest = { dialogToUnblock = null }, + icon = { + Icon( + imageVector = Icons.Default.LockOpen, + contentDescription = null, + tint = Color(0xFF4CAF50), + modifier = Modifier.size(32.dp) + ) + }, + title = { + Text( + text = "Unblock user?", + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + }, + text = { + val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } + Text( + text = "$displayName will be able to send you messages again.", + fontSize = 15.sp, + color = if (isDarkTheme) Color(0xFFAAAAAA) else Color(0xFF666666) + ) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + chatsViewModel.unblockUser(dialog.opponentKey) + dialogToUnblock = null + } + }, + colors = ButtonDefaults.textButtonColors( + contentColor = Color(0xFF4CAF50) + ) + ) { + Text("Unblock", fontWeight = FontWeight.SemiBold) + } + }, + dismissButton = { + TextButton( + onClick = { dialogToUnblock = null } + ) { + Text("Cancel", fontWeight = FontWeight.Medium) + } + }, + containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, + shape = RoundedCornerShape(16.dp) + ) + } } // Close Box } @@ -791,6 +981,155 @@ fun DrawerMenuItem( } } +/** + * 🔥 Swipeable wrapper для DialogItem с кнопками Block и Delete + * Свайп влево показывает действия (как в React Native версии) + */ +@Composable +fun SwipeableDialogItem( + dialog: DialogUiModel, + isDarkTheme: Boolean, + isTyping: Boolean = false, + isBlocked: Boolean = false, + isSavedMessages: Boolean = false, + onClick: () -> Unit, + onDelete: () -> Unit = {}, + onBlock: () -> Unit = {}, + onUnblock: () -> Unit = {} +) { + val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) + var offsetX by remember { mutableStateOf(0f) } + val swipeWidthDp = if (isSavedMessages) 80.dp else 160.dp + val density = androidx.compose.ui.platform.LocalDensity.current + val swipeWidthPx = with(density) { swipeWidthDp.toPx() } + + // Фиксированная высота элемента (как в DialogItem) + val itemHeight = 80.dp + + // Анимация возврата + val animatedOffsetX by animateFloatAsState( + targetValue = offsetX, + animationSpec = spring(dampingRatio = 0.7f, stiffness = 400f), + label = "swipeOffset" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(itemHeight) + .clipToBounds() + ) { + // 1. КНОПКИ - позиционированы справа, всегда видны при свайпе + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .height(itemHeight) + .width(swipeWidthDp) + ) { + // Кнопка Block/Unblock (только если не Saved Messages) + if (!isSavedMessages) { + Box( + modifier = Modifier + .width(80.dp) + .fillMaxHeight() + .background(if (isBlocked) Color(0xFF4CAF50) else Color(0xFFFF6B6B)) + .clickable { + if (isBlocked) onUnblock() else onBlock() + offsetX = 0f + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = if (isBlocked) Icons.Default.LockOpen else Icons.Default.Block, + contentDescription = if (isBlocked) "Unblock" else "Block", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (isBlocked) "Unblock" else "Block", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // Кнопка Delete + Box( + modifier = Modifier + .width(80.dp) + .fillMaxHeight() + .background(PrimaryBlue) + .clickable { + onDelete() + offsetX = 0f + }, + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "Delete", + tint = Color.White, + modifier = Modifier.size(22.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Delete", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + } + + // 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе + Box( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(animatedOffsetX.toInt(), 0) } + .background(backgroundColor) + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + // Если свайпнули больше чем на половину - фиксируем + if (kotlin.math.abs(offsetX) > swipeWidthPx / 2) { + offsetX = -swipeWidthPx + } else { + offsetX = 0f + } + }, + onDragCancel = { + offsetX = 0f + }, + onHorizontalDrag = { _, dragAmount -> + // Только свайп влево (отрицательное значение) + val newOffset = offsetX + dragAmount + offsetX = newOffset.coerceIn(-swipeWidthPx, 0f) + } + ) + } + ) { + DialogItem( + dialog = dialog, + isDarkTheme = isDarkTheme, + isTyping = isTyping, + onClick = onClick + ) + } + } +} + /** Элемент диалога из базы данных - ОПТИМИЗИРОВАННЫЙ */ @Composable fun DialogItem(dialog: DialogUiModel, isDarkTheme: Boolean, isTyping: Boolean = false, onClick: () -> Unit) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 7662519..845a228 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -168,4 +168,64 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio online = dialog.isOnline ) } + + /** + * Удалить диалог и все сообщения с собеседником + */ + suspend fun deleteDialog(opponentKey: String) { + if (currentAccount.isEmpty()) return + + try { + // Удаляем все сообщения + database.messageDao().deleteDialog(currentAccount, opponentKey) + // Удаляем диалог + database.dialogDao().deleteDialog(currentAccount, opponentKey) + } catch (e: Exception) { + android.util.Log.e("ChatsListViewModel", "Error deleting dialog", e) + } + } + + /** + * Заблокировать пользователя + */ + suspend fun blockUser(publicKey: String) { + if (currentAccount.isEmpty()) return + + try { + database.blacklistDao().blockUser( + com.rosetta.messenger.database.BlacklistEntity( + publicKey = publicKey, + account = currentAccount + ) + ) + } catch (e: Exception) { + android.util.Log.e("ChatsListViewModel", "Error blocking user", e) + } + } + + /** + * Разблокировать пользователя + */ + suspend fun unblockUser(publicKey: String) { + if (currentAccount.isEmpty()) return + + try { + database.blacklistDao().unblockUser(publicKey, currentAccount) + } catch (e: Exception) { + android.util.Log.e("ChatsListViewModel", "Error unblocking user", e) + } + } + + /** + * Проверить заблокирован ли пользователь + */ + suspend fun isUserBlocked(publicKey: String): Boolean { + if (currentAccount.isEmpty()) return false + + return try { + database.blacklistDao().isUserBlocked(publicKey, currentAccount) + } catch (e: Exception) { + false + } + } }