android: remove wallet menu and continue chat/settings localization
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-03-11 04:49:48 +03:00
parent a4fd60919e
commit cd7fb878b3
4 changed files with 703 additions and 137 deletions

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat package ru.daemonlord.messenger.ui.chat
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -67,6 +68,7 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
@@ -81,7 +83,9 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -93,7 +97,10 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -106,6 +113,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Forward import androidx.compose.material.icons.automirrored.filled.Forward
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -125,10 +133,10 @@ import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.FormatBold import androidx.compose.material.icons.filled.FormatBold
@@ -141,6 +149,7 @@ import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import coil.compose.AsyncImage import coil.compose.AsyncImage
import ru.daemonlord.messenger.R
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -278,6 +287,9 @@ fun ChatRoute(
onInlineSearchChanged = viewModel::onInlineSearchChanged, onInlineSearchChanged = viewModel::onInlineSearchChanged,
onJumpInlineSearch = viewModel::jumpInlineSearch, onJumpInlineSearch = viewModel::jumpInlineSearch,
onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId, onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId,
onToggleChatNotifications = viewModel::onToggleChatNotifications,
onClearHistory = viewModel::onClearHistory,
onDeleteChat = viewModel::onDeleteOrLeaveChat,
) )
} }
@@ -312,8 +324,12 @@ fun ChatScreen(
onInlineSearchChanged: (String) -> Unit, onInlineSearchChanged: (String) -> Unit,
onJumpInlineSearch: (Boolean) -> Unit, onJumpInlineSearch: (Boolean) -> Unit,
onVisibleIncomingMessageId: (Long?) -> Unit, onVisibleIncomingMessageId: (Long?) -> Unit,
onToggleChatNotifications: () -> Unit,
onClearHistory: () -> Unit,
onDeleteChat: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val allImageUrls = remember(state.messages) { val allImageUrls = remember(state.messages) {
@@ -344,7 +360,25 @@ fun ChatScreen(
.distinct() .distinct()
} }
val isChannelChat = state.chatType.equals("channel", ignoreCase = true) val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
val isPrivateChat = state.chatType.equals("private", ignoreCase = true)
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) } val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
val senderNameByUserId = remember(state.messages) {
state.messages
.mapNotNull { message ->
val name = message.senderDisplayName?.trim().orEmpty()
if (name.isBlank()) null else message.senderId to name
}
.toMap()
}
val replyAuthorByMessageId = remember(state.messages, senderNameByUserId) {
state.messages.associate { message ->
val resolved = message.senderDisplayName
?.takeIf { it.isNotBlank() }
?: senderNameByUserId[message.senderId]
?: "Unknown user"
message.id to resolved
}
}
var didInitialAutoScroll by remember(state.chatId) { mutableStateOf(false) } var didInitialAutoScroll by remember(state.chatId) { mutableStateOf(false) }
var viewerImageIndex by remember { mutableStateOf<Int?>(null) } var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) } var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -365,6 +399,8 @@ fun ChatScreen(
var giphyErrorMessage by remember { mutableStateOf<String?>(null) } var giphyErrorMessage by remember { mutableStateOf<String?>(null) }
var isPickerSending by remember { mutableStateOf(false) } var isPickerSending by remember { mutableStateOf(false) }
var showChatMenu by remember { mutableStateOf(false) } var showChatMenu by remember { mutableStateOf(false) }
var showClearHistoryDialog by remember { mutableStateOf(false) }
var showDeleteChatDialog by remember { mutableStateOf(false) }
var showChatInfoSheet by remember { mutableStateOf(false) } var showChatInfoSheet by remember { mutableStateOf(false) }
var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) } var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) }
var composerValue by remember { mutableStateOf(TextFieldValue(state.inputText)) } var composerValue by remember { mutableStateOf(TextFieldValue(state.inputText)) }
@@ -393,6 +429,22 @@ fun ChatScreen(
lastVisibleIndex < lastIndex - 1 lastVisibleIndex < lastIndex - 1
} }
} }
val isLightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f
val chatTopBarColor = if (isLightTheme) {
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)
} else {
MaterialTheme.colorScheme.surface
.copy(alpha = 0.92f)
.compositeOver(Color(0xFF1B1A22))
}
if (!view.isInEditMode) {
SideEffect {
val window = (context as? Activity)?.window ?: return@SideEffect
window.statusBarColor = chatTopBarColor.toArgb()
WindowCompat.getInsetsController(window, view)?.isAppearanceLightStatusBars =
chatTopBarColor.luminance() > 0.5f
}
}
LaunchedEffect(state.isRecordingVoice) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -439,6 +491,11 @@ fun ChatScreen(
onInlineSearchChanged("") onInlineSearchChanged("")
} }
} }
LaunchedEffect(state.chatDeletedNonce) {
if (state.chatDeletedNonce > 0L) {
onBack()
}
}
LaunchedEffect(emojiPickerTab, gifSearchQuery, giphyApiKey) { LaunchedEffect(emojiPickerTab, gifSearchQuery, giphyApiKey) {
if (emojiPickerTab != ComposerPickerTab.Gif) return@LaunchedEffect if (emojiPickerTab != ComposerPickerTab.Gif) return@LaunchedEffect
val query = gifSearchQuery.trim() val query = gifSearchQuery.trim()
@@ -486,11 +543,19 @@ fun ChatScreen(
.fillMaxSize() .fillMaxSize()
.background( .background(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf( colors = if (isLightTheme) {
Color(0xFF1B1A22), listOf(
Color(0xFF242034), Color(0xFFE1DCEC),
Color(0xFF1A202A), Color(0xFFD8D2E4),
), Color(0xFFCFC8DC),
)
} else {
listOf(
Color(0xFF1B1A22),
Color(0xFF242034),
Color(0xFF1A202A),
)
},
), ),
) )
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
@@ -500,7 +565,7 @@ fun ChatScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) .background(chatTopBarColor)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -533,7 +598,7 @@ fun ChatScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) .background(chatTopBarColor)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -610,15 +675,15 @@ fun ChatScreen(
onDismissRequest = { showChatMenu = false }, onDismissRequest = { showChatMenu = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Chat info") }, text = { Text(stringResource(id = R.string.chat_menu_notifications)) },
leadingIcon = { Icon(Icons.Filled.Info, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.Notifications, contentDescription = null) },
onClick = { onClick = {
showChatMenu = false showChatMenu = false
showChatInfoSheet = true onToggleChatNotifications()
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Search") }, text = { Text(stringResource(id = R.string.chat_menu_search)) },
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
onClick = { onClick = {
showChatMenu = false showChatMenu = false
@@ -626,19 +691,28 @@ fun ChatScreen(
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Change wallpaper") }, text = { Text(stringResource(id = R.string.chat_menu_change_wallpaper)) },
leadingIcon = { Icon(Icons.Filled.Wallpaper, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.Wallpaper, contentDescription = null) },
onClick = { showChatMenu = false }, onClick = {
showChatMenu = false
Toast.makeText(context, context.getString(R.string.chat_wallpaper_coming_soon), Toast.LENGTH_SHORT).show()
},
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Notifications") }, text = { Text(stringResource(id = R.string.chat_menu_clear_history)) },
leadingIcon = { Icon(Icons.Filled.Notifications, contentDescription = null) },
onClick = { showChatMenu = false },
)
DropdownMenuItem(
text = { Text("Clear history") },
leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) },
onClick = { showChatMenu = false }, onClick = {
showChatMenu = false
showClearHistoryDialog = true
},
)
DropdownMenuItem(
text = { Text(if (isPrivateChat) stringResource(id = R.string.chat_delete_dialog) else stringResource(id = R.string.chat_leave_delete)) },
leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) },
onClick = {
showChatMenu = false
showDeleteChatDialog = true
},
) )
} }
} }
@@ -657,7 +731,7 @@ fun ChatScreen(
value = state.inlineSearchQuery, value = state.inlineSearchQuery,
onValueChange = onInlineSearchChanged, onValueChange = onInlineSearchChanged,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
label = { Text("Search in chat") }, label = { Text(stringResource(id = R.string.chat_search_in_chat)) },
singleLine = true, singleLine = true,
) )
IconButton( IconButton(
@@ -678,7 +752,7 @@ fun ChatScreen(
} }
if (showInlineSearch && state.inlineSearchMatches.isNotEmpty()) { if (showInlineSearch && state.inlineSearchMatches.isNotEmpty()) {
Text( Text(
text = "Matches: ${state.inlineSearchMatches.size}", text = stringResource(id = R.string.chat_search_matches, state.inlineSearchMatches.size),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
@@ -702,7 +776,7 @@ fun ChatScreen(
) )
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = "Pinned message", text = stringResource(id = R.string.chat_pinned_message),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -823,6 +897,8 @@ fun ChatScreen(
isSelected = isSelected, isSelected = isSelected,
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
isInlineHighlighted = state.highlightedMessageId == message.id, isInlineHighlighted = state.highlightedMessageId == message.id,
senderNameByUserId = senderNameByUserId,
replyAuthorByMessageId = replyAuthorByMessageId,
reactions = state.reactionByMessageId[message.id].orEmpty(), reactions = state.reactionByMessageId[message.id].orEmpty(),
onAttachmentImageClick = { imageUrl -> onAttachmentImageClick = { imageUrl ->
val idx = allImageUrls.indexOf(imageUrl) val idx = allImageUrls.indexOf(imageUrl)
@@ -962,7 +1038,7 @@ fun ChatScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null) Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null)
Text("Reply", style = MaterialTheme.typography.bodyLarge) Text(stringResource(id = R.string.chat_action_reply), style = MaterialTheme.typography.bodyLarge)
} }
} }
Surface( Surface(
@@ -984,7 +1060,7 @@ fun ChatScreen(
) { ) {
Icon(imageVector = Icons.Filled.Edit, contentDescription = null) Icon(imageVector = Icons.Filled.Edit, contentDescription = null)
Text( Text(
"Edit", stringResource(id = R.string.chat_action_edit),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = if (state.selectedCanEdit) { color = if (state.selectedCanEdit) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
@@ -1013,7 +1089,7 @@ fun ChatScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Icon(imageVector = Icons.AutoMirrored.Filled.Forward, contentDescription = null) Icon(imageVector = Icons.AutoMirrored.Filled.Forward, contentDescription = null)
Text("Forward", style = MaterialTheme.typography.bodyLarge) Text(stringResource(id = R.string.chat_action_forward), style = MaterialTheme.typography.bodyLarge)
} }
} }
Surface( Surface(
@@ -1041,7 +1117,7 @@ fun ChatScreen(
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
Text( Text(
"Delete", stringResource(id = R.string.chat_action_delete),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
@@ -1053,7 +1129,7 @@ fun ChatScreen(
onClearSelection() onClearSelection()
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("Close") } ) { Text(stringResource(id = R.string.common_close)) }
} }
} }
} }
@@ -1072,14 +1148,14 @@ fun ChatScreen(
) { ) {
Text( Text(
text = if (state.forwardingMessageIds.size == 1) { text = if (state.forwardingMessageIds.size == 1) {
"Forward message #${state.forwardingMessageIds.first()}" stringResource(id = R.string.chat_forward_one, state.forwardingMessageIds.first())
} else { } else {
"Forward ${state.forwardingMessageIds.size} messages" stringResource(id = R.string.chat_forward_many, state.forwardingMessageIds.size)
}, },
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )
if (state.availableForwardTargets.isEmpty()) { if (state.availableForwardTargets.isEmpty()) {
Text("No available chats") Text(stringResource(id = R.string.chat_no_available_chats))
} else { } else {
state.availableForwardTargets.forEach { target -> state.availableForwardTargets.forEach { target ->
Button( Button(
@@ -1096,7 +1172,7 @@ fun ChatScreen(
enabled = !state.isForwarding, enabled = !state.isForwarding,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(if (state.isForwarding) "Forwarding..." else "Cancel") Text(if (state.isForwarding) stringResource(id = R.string.chat_forwarding) else stringResource(id = R.string.common_cancel))
} }
} }
} }
@@ -1262,9 +1338,14 @@ fun ChatScreen(
if (state.replyToMessage != null || state.editingMessage != null) { if (state.replyToMessage != null || state.editingMessage != null) {
val header = if (state.editingMessage != null) { val header = if (state.editingMessage != null) {
"Editing message #${state.editingMessage.id}" stringResource(id = R.string.chat_editing_message, state.editingMessage.id)
} else { } else {
"Reply to ${state.replyToMessage?.senderDisplayName ?: "#${state.replyToMessage?.id}"}" "${stringResource(id = R.string.chat_reply_to)} ${
state.replyToMessage?.senderDisplayName
?.takeIf { it.isNotBlank() }
?: state.replyToMessage?.senderId?.let { senderNameByUserId[it] }
?: stringResource(id = R.string.common_unknown_user)
}"
} }
Row( Row(
modifier = Modifier modifier = Modifier
@@ -1275,7 +1356,7 @@ fun ChatScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text(text = header, style = MaterialTheme.typography.bodySmall) Text(text = header, style = MaterialTheme.typography.bodySmall)
Button(onClick = onCancelComposeAction) { Text("Cancel") } Button(onClick = onCancelComposeAction) { Text(stringResource(id = R.string.common_cancel)) }
} }
} }
@@ -1288,6 +1369,7 @@ fun ChatScreen(
) )
} else if (isChannelChat && !state.canSendMessages) { } else if (isChannelChat && !state.canSendMessages) {
ChannelReadOnlyBar( ChannelReadOnlyBar(
onToggleNotifications = onToggleChatNotifications,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.navigationBarsPadding() .navigationBarsPadding()
@@ -1417,7 +1499,8 @@ fun ChatScreen(
onInputChanged(it.text) onInputChanged(it.text)
}, },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { Text("Message") }, placeholder = { Text(stringResource(id = R.string.chat_message_placeholder)) },
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
maxLines = 4, maxLines = 4,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
@@ -1486,13 +1569,13 @@ fun ChatScreen(
if (showDeleteDialog) { if (showDeleteDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteDialog = false }, onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete message") }, title = { Text(stringResource(id = R.string.chat_delete_message_title)) },
text = { text = {
Text( Text(
if (pendingDeleteForAll) { if (pendingDeleteForAll) {
"Delete selected message for everyone?" stringResource(id = R.string.chat_delete_message_for_everyone)
} else { } else {
"Delete selected message(s) for you?" stringResource(id = R.string.chat_delete_message_for_me)
} }
) )
}, },
@@ -1503,7 +1586,7 @@ fun ChatScreen(
showDeleteDialog = false showDeleteDialog = false
pendingDeleteForAll = false pendingDeleteForAll = false
}, },
) { Text("Delete") } ) { Text(stringResource(id = R.string.common_delete)) }
}, },
dismissButton = { dismissButton = {
TextButton( TextButton(
@@ -1511,7 +1594,51 @@ fun ChatScreen(
showDeleteDialog = false showDeleteDialog = false
pendingDeleteForAll = false pendingDeleteForAll = false
}, },
) { Text("Cancel") } ) { Text(stringResource(id = R.string.common_cancel)) }
},
)
}
if (showClearHistoryDialog) {
AlertDialog(
onDismissRequest = { showClearHistoryDialog = false },
title = { Text(stringResource(id = R.string.chat_menu_clear_history)) },
text = { Text(stringResource(id = R.string.chat_clear_history_confirm)) },
confirmButton = {
TextButton(
onClick = {
onClearHistory()
showClearHistoryDialog = false
},
) { Text(stringResource(id = R.string.chat_clear)) }
},
dismissButton = {
TextButton(onClick = { showClearHistoryDialog = false }) { Text(stringResource(id = R.string.common_cancel)) }
},
)
}
if (showDeleteChatDialog) {
AlertDialog(
onDismissRequest = { showDeleteChatDialog = false },
title = { Text(if (isPrivateChat) stringResource(id = R.string.chat_delete_dialog) else stringResource(id = R.string.chat_leave_chat)) },
text = {
Text(
if (isPrivateChat) {
stringResource(id = R.string.chat_delete_dialog_confirm)
} else {
stringResource(id = R.string.chat_leave_delete_confirm)
}
)
},
confirmButton = {
TextButton(
onClick = {
onDeleteChat()
showDeleteChatDialog = false
},
) { Text(if (isPrivateChat) stringResource(id = R.string.common_delete) else stringResource(id = R.string.chat_leave)) }
},
dismissButton = {
TextButton(onClick = { showDeleteChatDialog = false }) { Text(stringResource(id = R.string.common_cancel)) }
}, },
) )
} }
@@ -1577,7 +1704,7 @@ fun ChatScreen(
} }
}, },
enabled = currentIndex > 0, enabled = currentIndex > 0,
) { Text("Prev") } ) { Text(stringResource(id = R.string.chat_search_prev)) }
Button( Button(
onClick = { onClick = {
viewerImageIndex = if (allImageUrls.isEmpty()) null else { viewerImageIndex = if (allImageUrls.isEmpty()) null else {
@@ -1585,7 +1712,7 @@ fun ChatScreen(
} }
}, },
enabled = currentIndex < allImageUrls.lastIndex, enabled = currentIndex < allImageUrls.lastIndex,
) { Text("Next") } ) { Text(stringResource(id = R.string.chat_search_next)) }
} }
} }
} }
@@ -1826,7 +1953,7 @@ fun ChatScreen(
onValueChange = { gifSearchQuery = it }, onValueChange = { gifSearchQuery = it },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
placeholder = { Text("Search GIFs") }, placeholder = { Text(stringResource(id = R.string.chat_search_gifs)) },
) )
when { when {
isGiphyLoading -> { isGiphyLoading -> {
@@ -1945,6 +2072,8 @@ private fun MessageBubble(
isSelected: Boolean, isSelected: Boolean,
isMultiSelecting: Boolean, isMultiSelecting: Boolean,
isInlineHighlighted: Boolean, isInlineHighlighted: Boolean,
senderNameByUserId: Map<Long, String>,
replyAuthorByMessageId: Map<Long, String>,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>, reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit, onAttachmentImageClick: (String) -> Unit,
onAttachmentVideoClick: (String) -> Unit, onAttachmentVideoClick: (String) -> Unit,
@@ -1981,6 +2110,20 @@ private fun MessageBubble(
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.onSurfaceVariant
} }
val senderName = message.senderDisplayName
?.takeIf { it.isNotBlank() }
?: senderNameByUserId[message.senderId]
val legacyTextUrl = message.text?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) }
val hasLegacyStickerImage = message.attachments.isEmpty() &&
message.type.equals("image", ignoreCase = true) &&
!legacyTextUrl.isNullOrBlank() &&
isStickerAsset(fileUrl = legacyTextUrl, fileType = "image/url")
val singleImageAttachment = message.attachments.singleOrNull()?.takeIf {
it.fileType.lowercase().startsWith("image/")
}
val hasSingleStickerAttachment = singleImageAttachment?.let {
isStickerAsset(fileUrl = it.fileUrl, fileType = it.fileType)
} == true || hasLegacyStickerImage
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -1996,26 +2139,43 @@ private fun MessageBubble(
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(if (renderAsChannelPost) 0.94f else 0.8f) .fillMaxWidth(
.widthIn(min = if (renderAsChannelPost) 120.dp else 82.dp) if (hasSingleStickerAttachment) {
.background( if (renderAsChannelPost) 0.58f else 0.52f
color = when { } else if (renderAsChannelPost) {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer 0.94f
isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer } else {
else -> bubbleColor 0.8f
},
)
.widthIn(min = if (hasSingleStickerAttachment) 96.dp else if (renderAsChannelPost) 120.dp else 82.dp)
.then(
if (hasSingleStickerAttachment) {
Modifier
} else {
Modifier.background(
color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer
else -> bubbleColor
},
shape = bubbleShape,
)
}, },
shape = bubbleShape,
) )
.combinedClickable( .combinedClickable(
onClick = onClick, onClick = onClick,
onLongClick = onLongPress, onLongClick = onLongPress,
) )
.padding(horizontal = if (renderAsChannelPost) 11.dp else 10.dp, vertical = 7.dp), .padding(
horizontal = if (hasSingleStickerAttachment) 0.dp else if (renderAsChannelPost) 11.dp else 10.dp,
vertical = if (hasSingleStickerAttachment) 2.dp else 7.dp,
),
verticalArrangement = Arrangement.spacedBy(3.dp), verticalArrangement = Arrangement.spacedBy(3.dp),
) { ) {
if ((!alignAsOutgoing || renderAsChannelPost) && !message.senderDisplayName.isNullOrBlank()) { if ((!alignAsOutgoing || renderAsChannelPost) && !senderName.isNullOrBlank()) {
Text( Text(
text = message.senderDisplayName, text = senderName,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@@ -2031,7 +2191,10 @@ private fun MessageBubble(
} }
if (message.replyToMessageId != null) { if (message.replyToMessageId != null) {
val replyAuthor = message.replyPreviewSenderName?.takeIf { it.isNotBlank() } ?: "#${message.replyToMessageId}" val replyAuthor = message.replyPreviewSenderName
?.takeIf { it.isNotBlank() }
?: replyAuthorByMessageId[message.replyToMessageId]
?: "Unknown user"
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]" val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
Row( Row(
modifier = Modifier modifier = Modifier
@@ -2092,15 +2255,25 @@ private fun MessageBubble(
) )
} }
if (isLegacyImageUrlMessage && textUrl != null) { if (isLegacyImageUrlMessage) {
val badgeLabel = mediaBadgeLabel(fileType = "image/url", url = textUrl) val isLegacyStickerMessage = isStickerAsset(fileUrl = textUrl, fileType = "image/url")
val badgeLabel = if (isLegacyStickerMessage) null else mediaBadgeLabel(fileType = "image/url", url = textUrl)
val openable = !isLegacyStickerMessage && badgeLabel == null
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .then(
.height(188.dp) if (isLegacyStickerMessage) {
Modifier
.size(176.dp)
} else {
Modifier
.fillMaxWidth()
.height(188.dp)
},
)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.let { base -> .let { base ->
if (isGifOrStickerLegacyImage) { if (isGifOrStickerLegacyImage || !openable) {
base base
} else { } else {
base.clickable { onAttachmentImageClick(textUrl) } base.clickable { onAttachmentImageClick(textUrl) }
@@ -2111,7 +2284,7 @@ private fun MessageBubble(
model = textUrl, model = textUrl,
contentDescription = "Image", contentDescription = "Image",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = if (isLegacyStickerMessage) ContentScale.Fit else ContentScale.Crop,
) )
if (!badgeLabel.isNullOrBlank()) { if (!badgeLabel.isNullOrBlank()) {
MediaTypeBadge( MediaTypeBadge(
@@ -2124,7 +2297,7 @@ private fun MessageBubble(
} }
} }
if (isLegacyVideoUrlMessage && textUrl != null) { if (isLegacyVideoUrlMessage) {
VideoAttachmentCard( VideoAttachmentCard(
url = textUrl, url = textUrl,
fileType = message.type, fileType = message.type,
@@ -2157,12 +2330,21 @@ private fun MessageBubble(
if (imageAttachments.isNotEmpty()) { if (imageAttachments.isNotEmpty()) {
if (imageAttachments.size == 1) { if (imageAttachments.size == 1) {
val single = imageAttachments.first() val single = imageAttachments.first()
val badgeLabel = mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl) val isStickerImage = isStickerAsset(fileUrl = single.fileUrl, fileType = single.fileType)
val openable = badgeLabel == null val badgeLabel = if (isStickerImage) null else mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl)
val openable = !isStickerImage && badgeLabel == null
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .then(
.height(188.dp) if (isStickerImage) {
Modifier
.size(176.dp)
} else {
Modifier
.fillMaxWidth()
.height(188.dp)
},
)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.let { base -> .let { base ->
if (openable) base.clickable { onAttachmentImageClick(single.fileUrl) } else base if (openable) base.clickable { onAttachmentImageClick(single.fileUrl) } else base
@@ -2172,7 +2354,7 @@ private fun MessageBubble(
model = single.fileUrl, model = single.fileUrl,
contentDescription = "Image", contentDescription = "Image",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = if (isStickerImage) ContentScale.Fit else ContentScale.Crop,
) )
if (!badgeLabel.isNullOrBlank()) { if (!badgeLabel.isNullOrBlank()) {
MediaTypeBadge( MediaTypeBadge(
@@ -2279,12 +2461,16 @@ private fun MessageBubble(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
) { ) {
val status = when (message.status) { val status = if (isOutgoing && !renderAsChannelPost) {
"read" -> " ✓✓" when (message.status) {
"delivered" -> " ✓✓" "read" -> " ✓✓"
"sent" -> "" "delivered" -> " "
"pending" -> " " "sent" -> " "
else -> "" "pending" -> ""
else -> ""
}
} else {
""
} }
Text( Text(
text = if (renderAsChannelPost) { text = if (renderAsChannelPost) {
@@ -2362,7 +2548,7 @@ private fun CircleVideoAttachmentPlayer(url: String) {
} }
}, },
) )
Text("Circle video", style = MaterialTheme.typography.labelSmall) Text(stringResource(id = R.string.chat_circle_video), style = MaterialTheme.typography.labelSmall)
} }
} }
@@ -2488,43 +2674,28 @@ private fun DaySeparatorChip(label: String) {
@Composable @Composable
private fun ChannelReadOnlyBar( private fun ChannelReadOnlyBar(
onToggleNotifications: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f),
modifier = Modifier.size(46.dp),
) {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search in channel")
}
}
Surface( Surface(
shape = RoundedCornerShape(999.dp), shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f),
modifier = Modifier.weight(1f), modifier = Modifier
.weight(1f)
.clickable { onToggleNotifications() },
) { ) {
Text( Text(
text = "Включить звук", text = stringResource(id = R.string.chat_enable_sound),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center, textAlign = androidx.compose.ui.text.style.TextAlign.Center,
) )
} }
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f),
modifier = Modifier.size(46.dp),
) {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Filled.Notifications, contentDescription = "Channel notifications")
}
}
} }
} }
@@ -2553,7 +2724,7 @@ private fun MultiSelectActionBar(
enabled = canReply, enabled = canReply,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Reply") Text(stringResource(id = R.string.chat_action_reply))
} }
} }
Surface( Surface(
@@ -2566,7 +2737,7 @@ private fun MultiSelectActionBar(
enabled = selectedCount > 0, enabled = selectedCount > 0,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Forward") Text(stringResource(id = R.string.chat_action_forward))
} }
} }
} }
@@ -2578,7 +2749,7 @@ private fun parseMessageLocalDate(createdAt: String): LocalDate? {
}.getOrNull() }.getOrNull()
} }
private val daySeparatorFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru")) private val daySeparatorFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale.getDefault())
private fun formatDateSeparatorLabel(date: LocalDate): String { private fun formatDateSeparatorLabel(date: LocalDate): String {
val today = LocalDate.now() val today = LocalDate.now()
@@ -2630,8 +2801,8 @@ private fun VoiceRecordingStatusRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onCancel) { Text("Cancel") } Button(onClick = onCancel) { Text(stringResource(id = R.string.common_cancel)) }
Button(onClick = onSend) { Text("Send") } Button(onClick = onSend) { Text(stringResource(id = R.string.common_send)) }
} }
} }
} }
@@ -3521,7 +3692,16 @@ private fun isGifLikeUrl(url: String): Boolean {
private fun isStickerLikeUrl(url: String): Boolean { private fun isStickerLikeUrl(url: String): Boolean {
if (url.isBlank()) return false if (url.isBlank()) return false
val normalized = url.lowercase(Locale.getDefault()) val normalized = url.lowercase(Locale.getDefault())
return normalized.contains("twemoji") || normalized.endsWith(".webp") return normalized.contains("twemoji") ||
normalized.endsWith(".webp") ||
normalized.contains("sticker_") ||
normalized.contains("/stickers/")
}
private fun isStickerAsset(fileUrl: String, fileType: String): Boolean {
val normalizedType = fileType.lowercase(Locale.getDefault())
if (normalizedType.contains("webp")) return true
return isStickerLikeUrl(fileUrl)
} }
private fun mediaBadgeLabel(fileType: String, url: String): String? { private fun mediaBadgeLabel(fileType: String, url: String): String? {

View File

@@ -30,9 +30,11 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -40,6 +42,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -56,6 +59,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -68,17 +72,21 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DoneAll import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Inventory2 import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.appcompat.app.AppCompatDelegate
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import coil.compose.AsyncImage import coil.compose.AsyncImage
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
@@ -134,6 +142,7 @@ fun ChatListRoute(
onUpdateChatProfile = viewModel::updateChatProfile, onUpdateChatProfile = viewModel::updateChatProfile,
onClearChat = viewModel::clearChat, onClearChat = viewModel::clearChat,
onDeleteChat = viewModel::deleteChatForMe, onDeleteChat = viewModel::deleteChatForMe,
onDeleteChatForAll = viewModel::deleteChatForAll,
onToggleChatMute = viewModel::toggleChatMute, onToggleChatMute = viewModel::toggleChatMute,
onSelectManageChat = viewModel::onManagementChatSelected, onSelectManageChat = viewModel::onManagementChatSelected,
onCreateInvite = viewModel::createInvite, onCreateInvite = viewModel::createInvite,
@@ -173,6 +182,7 @@ fun ChatListScreen(
onUpdateChatProfile: (Long, String?, String?) -> Unit, onUpdateChatProfile: (Long, String?, String?) -> Unit,
onClearChat: (Long) -> Unit, onClearChat: (Long) -> Unit,
onDeleteChat: (Long) -> Unit, onDeleteChat: (Long) -> Unit,
onDeleteChatForAll: (Long) -> Unit,
onToggleChatMute: (Long) -> Unit, onToggleChatMute: (Long) -> Unit,
onSelectManageChat: (Long?) -> Unit, onSelectManageChat: (Long?) -> Unit,
onCreateInvite: (Long) -> Unit, onCreateInvite: (Long) -> Unit,
@@ -200,6 +210,13 @@ fun ChatListScreen(
var selectedManageChatIdText by remember { mutableStateOf("") } var selectedManageChatIdText by remember { mutableStateOf("") }
var manageUserIdText by remember { mutableStateOf("") } var manageUserIdText by remember { mutableStateOf("") }
var manageRoleText by remember { mutableStateOf("member") } var manageRoleText by remember { mutableStateOf("member") }
var showCreateGroupDialog by remember { mutableStateOf(false) }
var showCreateChannelDialog by remember { mutableStateOf(false) }
var quickCreateGroupTitle by remember { mutableStateOf("") }
var quickCreateChannelTitle by remember { mutableStateOf("") }
var quickCreateChannelHandle by remember { mutableStateOf("") }
var showDeleteChatsDialog by remember { mutableStateOf(false) }
var deleteSelectedForAll by remember { mutableStateOf(false) }
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val listState = rememberLazyListState() val listState = rememberLazyListState()
val selectedChats = remember(state.chats, selectedChatIds) { val selectedChats = remember(state.chats, selectedChatIds) {
@@ -282,9 +299,9 @@ fun ChatListScreen(
when { when {
selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString() selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString()
isSearchMode -> "" isSearchMode -> ""
state.isConnecting -> "Connecting..." state.isConnecting -> stringResource(id = R.string.chats_connecting)
state.selectedTab == ChatTab.ARCHIVED -> "Archived" state.selectedTab == ChatTab.ARCHIVED -> stringResource(id = R.string.chats_archived)
else -> "Chats" else -> stringResource(id = R.string.nav_chats)
} }
) )
}, },
@@ -311,8 +328,7 @@ fun ChatListScreen(
) )
} }
IconButton(onClick = { IconButton(onClick = {
selectedChatIds.forEach { chatId -> onDeleteChat(chatId) } showDeleteChatsDialog = true
selectedChatIds = emptySet()
}) { }) {
Icon( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Delete,
@@ -394,45 +410,45 @@ fun ChatListScreen(
onDismissRequest = { showDefaultMenu = false }, onDismissRequest = { showDefaultMenu = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Day mode") }, text = {
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
Text(
if (isNight) {
stringResource(id = R.string.menu_day_mode)
} else {
stringResource(id = R.string.menu_night_mode)
}
)
},
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
onClick = { onClick = {
showDefaultMenu = false showDefaultMenu = false
Toast.makeText(context, "Theme switch in Settings.", Toast.LENGTH_SHORT).show() val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
if (isNight) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
Toast.makeText(context, context.getString(R.string.toast_day_mode_enabled), Toast.LENGTH_SHORT).show()
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
Toast.makeText(context, context.getString(R.string.toast_night_mode_enabled), Toast.LENGTH_SHORT).show()
}
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Create group") }, text = { Text(stringResource(id = R.string.menu_create_group)) },
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.Groups, contentDescription = null) },
onClick = { onClick = {
showDefaultMenu = false showDefaultMenu = false
managementExpanded = true showCreateGroupDialog = true
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("Create channel") }, text = { Text(stringResource(id = R.string.menu_saved)) },
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.BookmarkBorder, contentDescription = null) },
onClick = {
showDefaultMenu = false
managementExpanded = true
},
)
DropdownMenuItem(
text = { Text("Saved") },
leadingIcon = { Icon(Icons.Filled.Inventory2, contentDescription = null) },
onClick = { onClick = {
showDefaultMenu = false showDefaultMenu = false
onOpenSaved() onOpenSaved()
}, },
) )
DropdownMenuItem(
text = { Text("Proxy") },
leadingIcon = { Icon(Icons.Filled.NotificationsOff, contentDescription = null) },
onClick = {
showDefaultMenu = false
Toast.makeText(context, "Proxy settings will be added next.", Toast.LENGTH_SHORT).show()
},
)
} }
} }
} }
@@ -447,22 +463,22 @@ fun ChatListScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
FilterChip( FilterChip(
label = "All", label = stringResource(id = R.string.filter_all),
selected = state.selectedFilter == ChatListFilter.ALL, selected = state.selectedFilter == ChatListFilter.ALL,
onClick = { onFilterSelected(ChatListFilter.ALL) }, onClick = { onFilterSelected(ChatListFilter.ALL) },
) )
FilterChip( FilterChip(
label = "People", label = stringResource(id = R.string.filter_people),
selected = state.selectedFilter == ChatListFilter.PEOPLE, selected = state.selectedFilter == ChatListFilter.PEOPLE,
onClick = { onFilterSelected(ChatListFilter.PEOPLE) }, onClick = { onFilterSelected(ChatListFilter.PEOPLE) },
) )
FilterChip( FilterChip(
label = "Groups", label = stringResource(id = R.string.filter_groups),
selected = state.selectedFilter == ChatListFilter.GROUPS, selected = state.selectedFilter == ChatListFilter.GROUPS,
onClick = { onFilterSelected(ChatListFilter.GROUPS) }, onClick = { onFilterSelected(ChatListFilter.GROUPS) },
) )
FilterChip( FilterChip(
label = "Channels", label = stringResource(id = R.string.filter_channels),
selected = state.selectedFilter == ChatListFilter.CHANNELS, selected = state.selectedFilter == ChatListFilter.CHANNELS,
onClick = { onFilterSelected(ChatListFilter.CHANNELS) }, onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
) )
@@ -475,7 +491,7 @@ fun ChatListScreen(
) { ) {
when { when {
state.isLoading -> { state.isLoading -> {
CenterState(text = "Loading chats...", loading = true) CenterState(text = stringResource(id = R.string.chats_loading), loading = true)
} }
!state.errorMessage.isNullOrBlank() -> { !state.errorMessage.isNullOrBlank() -> {
@@ -483,7 +499,7 @@ fun ChatListScreen(
} }
state.chats.isEmpty() -> { state.chats.isEmpty() -> {
CenterState(text = "No chats found", loading = false) CenterState(text = stringResource(id = R.string.chats_not_found), loading = false)
} }
else -> { else -> {
@@ -759,6 +775,120 @@ fun ChatListScreen(
} }
} }
} }
if (showCreateGroupDialog) {
AlertDialog(
onDismissRequest = { showCreateGroupDialog = false },
title = { Text("Create group") },
text = {
OutlinedTextField(
value = quickCreateGroupTitle,
onValueChange = { quickCreateGroupTitle = it },
label = { Text("Group title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
TextButton(
onClick = {
val title = quickCreateGroupTitle.trim()
if (title.isNotBlank()) {
onCreateGroup(title, emptyList())
showCreateGroupDialog = false
quickCreateGroupTitle = ""
}
},
) { Text("Create") }
},
dismissButton = {
TextButton(onClick = { showCreateGroupDialog = false }) { Text("Cancel") }
},
)
}
if (showCreateChannelDialog) {
AlertDialog(
onDismissRequest = { showCreateChannelDialog = false },
title = { Text("Create channel") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = quickCreateChannelTitle,
onValueChange = { quickCreateChannelTitle = it },
label = { Text("Channel title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = quickCreateChannelHandle,
onValueChange = { quickCreateChannelHandle = it },
label = { Text("Handle") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
TextButton(
onClick = {
val title = quickCreateChannelTitle.trim()
val handle = quickCreateChannelHandle.trim()
if (title.isNotBlank() && handle.isNotBlank()) {
onCreateChannel(title, handle, null)
showCreateChannelDialog = false
quickCreateChannelTitle = ""
quickCreateChannelHandle = ""
}
},
) { Text("Create") }
},
dismissButton = {
TextButton(onClick = { showCreateChannelDialog = false }) { Text("Cancel") }
},
)
}
if (showDeleteChatsDialog) {
AlertDialog(
onDismissRequest = { showDeleteChatsDialog = false },
title = { Text("Delete selected chats") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Are you sure you want to delete selected chats?")
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { deleteSelectedForAll = !deleteSelectedForAll },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = deleteSelectedForAll,
onCheckedChange = { deleteSelectedForAll = it },
)
Text("Delete for all (where allowed)")
}
}
},
confirmButton = {
TextButton(
onClick = {
selectedChatIds.forEach { chatId ->
if (deleteSelectedForAll) onDeleteChatForAll(chatId) else onDeleteChat(chatId)
}
selectedChatIds = emptySet()
deleteSelectedForAll = false
showDeleteChatsDialog = false
},
) { Text("Delete") }
},
dismissButton = {
TextButton(
onClick = {
showDeleteChatsDialog = false
deleteSelectedForAll = false
},
) { Text("Cancel") }
},
)
}
} }
@Composable @Composable

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Benya Messenger</string>
<string name="nav_chats">Чаты</string>
<string name="nav_contacts">Контакты</string>
<string name="nav_settings">Настройки</string>
<string name="nav_profile">Профиль</string>
<string name="chats_connecting">Подключение...</string>
<string name="chats_archived">Архив</string>
<string name="chats_loading">Загрузка чатов...</string>
<string name="chats_not_found">Чаты не найдены</string>
<string name="filter_all">Все</string>
<string name="filter_people">Люди</string>
<string name="filter_groups">Группы</string>
<string name="filter_channels">Каналы</string>
<string name="menu_day_mode">Дневной режим</string>
<string name="menu_night_mode">Ночной режим</string>
<string name="menu_create_group">Создать группу</string>
<string name="menu_saved">Избранное</string>
<string name="toast_day_mode_enabled">Включен дневной режим.</string>
<string name="toast_night_mode_enabled">Включен ночной режим.</string>
<string name="common_cancel">Отмена</string>
<string name="common_close">Закрыть</string>
<string name="common_delete">Удалить</string>
<string name="common_send">Отправить</string>
<string name="common_unknown_user">Неизвестный пользователь</string>
<string name="chat_menu_notifications">Уведомления</string>
<string name="chat_menu_search">Поиск</string>
<string name="chat_menu_change_wallpaper">Изменить обои</string>
<string name="chat_menu_clear_history">Очистить историю</string>
<string name="chat_wallpaper_coming_soon">Смена обоев будет добавлена позже.</string>
<string name="chat_delete_dialog">Удалить диалог</string>
<string name="chat_leave_delete">Выйти и удалить чат</string>
<string name="chat_leave_chat">Выйти из чата</string>
<string name="chat_leave">Выйти</string>
<string name="chat_search_in_chat">Поиск по чату</string>
<string name="chat_search_prev">Назад</string>
<string name="chat_search_next">Далее</string>
<string name="chat_search_gifs">Поиск GIF</string>
<string name="chat_search_matches">Совпадений: %1$d</string>
<string name="chat_pinned_message">Закрепленное сообщение</string>
<string name="chat_message_placeholder">Сообщение</string>
<string name="chat_action_reply">Ответить</string>
<string name="chat_action_edit">Изменить</string>
<string name="chat_action_forward">Переслать</string>
<string name="chat_action_delete">Удалить</string>
<string name="chat_delete_message_title">Удалить сообщение</string>
<string name="chat_forward_one">Переслать сообщение #%1$d</string>
<string name="chat_forward_many">Переслать %1$d сообщений</string>
<string name="chat_no_available_chats">Нет доступных чатов</string>
<string name="chat_forwarding">Пересылка...</string>
<string name="chat_editing_message">Редактирование сообщения #%1$d</string>
<string name="chat_reply_to">Ответ</string>
<string name="chat_delete_message_for_everyone">Удалить выбранное сообщение для всех?</string>
<string name="chat_delete_message_for_me">Удалить выбранные сообщения у вас?</string>
<string name="chat_clear_history_confirm">Удалить все сообщения в этом чате? Это действие нельзя отменить.</string>
<string name="chat_clear">Очистить</string>
<string name="chat_delete_dialog_confirm">Удалить диалог у вас? История будет очищена.</string>
<string name="chat_leave_delete_confirm">Выйти из чата и убрать его из списка?</string>
<string name="chat_enable_sound">Включить звук</string>
<string name="chat_circle_video">Видеокружок</string>
<string name="settings_user_fallback">Пользователь</string>
<string name="settings_accounts_header">АККАУНТЫ</string>
<string name="settings_no_active_account">Нет активного аккаунта</string>
<string name="settings_add_account">Добавить аккаунт</string>
<string name="settings_folder_account">Аккаунт</string>
<string name="settings_folder_chat">Настройки чатов</string>
<string name="settings_folder_privacy">Конфиденциальность</string>
<string name="settings_folder_notifications">Уведомления</string>
<string name="settings_folder_data">Данные и память</string>
<string name="settings_folder_folders">Папки с чатами</string>
<string name="settings_folder_devices">Устройства</string>
<string name="settings_folder_power">Энергосбережение</string>
<string name="settings_folder_language">Язык</string>
<string name="settings_account_subtitle">Номер, имя пользователя, «О себе»</string>
<string name="settings_chat_subtitle">Обои, ночной режим, анимации</string>
<string name="settings_privacy_subtitle">Время захода, устройства, ключи доступа</string>
<string name="settings_notifications_subtitle">Звуки, звонки, счетчик сообщений</string>
<string name="settings_data_subtitle">Настройки загрузки медиафайлов</string>
<string name="settings_folders_subtitle">Сортировка чатов по папкам</string>
<string name="settings_devices_subtitle">Управление активными сессиями</string>
<string name="settings_power_subtitle">Экономия энергии при низком заряде</string>
<string name="settings_placeholder_data">Этот раздел будет расширен на следующем шаге.</string>
<string name="settings_placeholder_folders">Управление папками чатов будет добавлено на следующей итерации.</string>
<string name="settings_placeholder_power">Настройки энергосбережения будут добавлены отдельным этапом.</string>
<string name="settings_language_title">Язык приложения</string>
<string name="settings_language_system_subtitle">Использовать язык устройства</string>
<string name="settings_language_english_subtitle">Английский</string>
<string name="language_system">Системный</string>
<string name="language_russian">Русский</string>
<string name="language_english">Английский</string>
<string name="settings_accounts">Аккаунты</string>
<string name="settings_active">Активный</string>
<string name="settings_switch">Переключить</string>
<string name="settings_open_profile">Открыть профиль</string>
<string name="settings_logout">Выйти</string>
<string name="settings_appearance">Оформление</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Темная</string>
<string name="theme_system">Системная</string>
<string name="settings_enable_notifications">Включить уведомления</string>
<string name="settings_show_preview">Показывать превью сообщений</string>
<string name="settings_refresh_notifications">Обновить историю уведомлений</string>
<string name="settings_recent_notifications">Последние уведомления</string>
<string name="settings_no_notifications_yet">Пока нет серверных уведомлений.</string>
<string name="privacy_private_messages">Личные сообщения</string>
<string name="privacy_last_seen">Время захода</string>
<string name="privacy_avatar">Аватар</string>
<string name="privacy_group_invites">Приглашения в группы</string>
<string name="privacy_save">Сохранить приватность</string>
<string name="settings_sessions_security">Сессии и безопасность</string>
<string name="settings_unknown_device">Неизвестное устройство</string>
<string name="settings_unknown_ip">Неизвестный IP</string>
<string name="settings_revoke">Отозвать</string>
<string name="settings_revoke_all">Отозвать все сессии</string>
<string name="settings_2fa_code">Код 2FA</string>
<string name="settings_enable">Включить</string>
<string name="settings_disable">Выключить</string>
<string name="settings_recovery_regen_code">Код для регенерации recovery</string>
<string name="settings_regenerate_recovery_codes">Сгенерировать recovery-коды заново</string>
<string name="privacy_everyone">все</string>
<string name="privacy_contacts">контакты</string>
<string name="privacy_nobody">никто</string>
</resources>

View File

@@ -1,4 +1,130 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Benya Messenger</string> <string name="app_name">Benya Messenger</string>
<string name="nav_chats">Chats</string>
<string name="nav_contacts">Contacts</string>
<string name="nav_settings">Settings</string>
<string name="nav_profile">Profile</string>
<string name="chats_connecting">Connecting...</string>
<string name="chats_archived">Archived</string>
<string name="chats_loading">Loading chats...</string>
<string name="filter_all">All</string>
<string name="filter_people">People</string>
<string name="filter_groups">Groups</string>
<string name="filter_channels">Channels</string>
<string name="menu_day_mode">Day mode</string>
<string name="menu_night_mode">Night mode</string>
<string name="menu_create_group">Create group</string>
<string name="menu_saved">Saved</string>
<string name="toast_day_mode_enabled">Day mode enabled.</string>
<string name="toast_night_mode_enabled">Night mode enabled.</string>
<string name="chats_not_found">No chats found</string>
<string name="common_cancel">Cancel</string>
<string name="common_close">Close</string>
<string name="common_delete">Delete</string>
<string name="common_send">Send</string>
<string name="common_unknown_user">Unknown user</string>
<string name="chat_menu_notifications">Notifications</string>
<string name="chat_menu_search">Search</string>
<string name="chat_menu_change_wallpaper">Change wallpaper</string>
<string name="chat_menu_clear_history">Clear history</string>
<string name="chat_wallpaper_coming_soon">Wallpaper change will be added later.</string>
<string name="chat_delete_dialog">Delete dialog</string>
<string name="chat_leave_delete">Leave and delete chat</string>
<string name="chat_leave_chat">Leave chat</string>
<string name="chat_leave">Leave</string>
<string name="chat_search_in_chat">Search in chat</string>
<string name="chat_search_prev">Prev</string>
<string name="chat_search_next">Next</string>
<string name="chat_search_gifs">Search GIFs</string>
<string name="chat_search_matches">Matches: %1$d</string>
<string name="chat_pinned_message">Pinned message</string>
<string name="chat_message_placeholder">Message</string>
<string name="chat_action_reply">Reply</string>
<string name="chat_action_edit">Edit</string>
<string name="chat_action_forward">Forward</string>
<string name="chat_action_delete">Delete</string>
<string name="chat_delete_message_title">Delete message</string>
<string name="chat_forward_one">Forward message #%1$d</string>
<string name="chat_forward_many">Forward %1$d messages</string>
<string name="chat_no_available_chats">No available chats</string>
<string name="chat_forwarding">Forwarding...</string>
<string name="chat_editing_message">Editing message #%1$d</string>
<string name="chat_reply_to">Reply to</string>
<string name="chat_delete_message_for_everyone">Delete selected message for everyone?</string>
<string name="chat_delete_message_for_me">Delete selected message(s) for you?</string>
<string name="chat_clear_history_confirm">Delete all messages in this chat? This action cannot be undone.</string>
<string name="chat_clear">Clear</string>
<string name="chat_delete_dialog_confirm">Delete dialog for you? History will be cleared.</string>
<string name="chat_leave_delete_confirm">Leave the chat and remove it from your list?</string>
<string name="chat_enable_sound">Enable sound</string>
<string name="chat_circle_video">Circle video</string>
<string name="settings_user_fallback">User</string>
<string name="settings_accounts_header">ACCOUNTS</string>
<string name="settings_no_active_account">No active account</string>
<string name="settings_add_account">Add account</string>
<string name="settings_folder_account">Account</string>
<string name="settings_folder_chat">Chat settings</string>
<string name="settings_folder_privacy">Privacy</string>
<string name="settings_folder_notifications">Notifications</string>
<string name="settings_folder_data">Data and storage</string>
<string name="settings_folder_folders">Chat folders</string>
<string name="settings_folder_devices">Devices</string>
<string name="settings_folder_power">Power saving</string>
<string name="settings_folder_language">Language</string>
<string name="settings_account_subtitle">Phone number, username, bio</string>
<string name="settings_chat_subtitle">Wallpaper, night mode, animations</string>
<string name="settings_privacy_subtitle">Last seen, devices, passkeys</string>
<string name="settings_notifications_subtitle">Sounds, message counter</string>
<string name="settings_data_subtitle">Media download settings</string>
<string name="settings_folders_subtitle">Sort chats by folders</string>
<string name="settings_devices_subtitle">Manage active sessions</string>
<string name="settings_power_subtitle">Save power on low battery</string>
<string name="settings_placeholder_data">This section will be expanded in the next step.</string>
<string name="settings_placeholder_folders">Chat folders management will be added in next iteration.</string>
<string name="settings_placeholder_power">Power saving settings will be added in a separate step.</string>
<string name="settings_language_title">App language</string>
<string name="settings_language_system_subtitle">Use device language</string>
<string name="settings_language_english_subtitle">English</string>
<string name="language_system">System</string>
<string name="language_russian">Russian</string>
<string name="language_english">English</string>
<string name="settings_accounts">Accounts</string>
<string name="settings_active">Active</string>
<string name="settings_switch">Switch</string>
<string name="settings_open_profile">Open profile</string>
<string name="settings_logout">Logout</string>
<string name="settings_appearance">Appearance</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">System</string>
<string name="settings_enable_notifications">Enable notifications</string>
<string name="settings_show_preview">Show message preview</string>
<string name="settings_refresh_notifications">Refresh notification history</string>
<string name="settings_recent_notifications">Recent notifications</string>
<string name="settings_no_notifications_yet">No server notifications yet.</string>
<string name="privacy_private_messages">Private messages</string>
<string name="privacy_last_seen">Last seen</string>
<string name="privacy_avatar">Avatar</string>
<string name="privacy_group_invites">Group invites</string>
<string name="privacy_save">Save privacy</string>
<string name="settings_sessions_security">Sessions &amp; Security</string>
<string name="settings_unknown_device">Unknown device</string>
<string name="settings_unknown_ip">Unknown IP</string>
<string name="settings_revoke">Revoke</string>
<string name="settings_revoke_all">Revoke all sessions</string>
<string name="settings_2fa_code">2FA code</string>
<string name="settings_enable">Enable</string>
<string name="settings_disable">Disable</string>
<string name="settings_recovery_regen_code">Code for recovery regeneration</string>
<string name="settings_regenerate_recovery_codes">Regenerate recovery codes</string>
<string name="privacy_everyone">everyone</string>
<string name="privacy_contacts">contacts</string>
<string name="privacy_nobody">nobody</string>
</resources> </resources>