android: fix chat theme toggle and add member management in chat info
Some checks failed
Android CI / android (push) Failing after 5m24s
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-03-11 05:12:17 +03:00
parent cdb45abb21
commit 0510a2717a
8 changed files with 380 additions and 16 deletions

View File

@@ -48,6 +48,7 @@ fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
chatId = message.chatId, chatId = message.chatId,
senderId = message.senderId, senderId = message.senderId,
senderDisplayName = message.senderDisplayName, senderDisplayName = message.senderDisplayName,
senderUsername = message.senderUsername,
type = message.type, type = message.type,
text = message.text, text = message.text,
createdAt = message.createdAt, createdAt = message.createdAt,

View File

@@ -686,6 +686,7 @@ class NetworkMessageRepository @Inject constructor(
chatId = chatId, chatId = chatId,
senderId = senderId, senderId = senderId,
senderDisplayName = senderDisplayName, senderDisplayName = senderDisplayName,
senderUsername = senderUsername,
type = type, type = type,
text = text, text = text,
createdAt = createdAt, createdAt = createdAt,

View File

@@ -5,6 +5,7 @@ data class MessageItem(
val chatId: Long, val chatId: Long,
val senderId: Long, val senderId: Long,
val senderDisplayName: String?, val senderDisplayName: String?,
val senderUsername: String? = null,
val type: String, val type: String,
val text: String?, val text: String?,
val createdAt: String, val createdAt: String,

View File

@@ -48,6 +48,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -161,6 +162,8 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.daemonlord.messenger.BuildConfig import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
import java.time.Instant import java.time.Instant
@@ -290,6 +293,12 @@ fun ChatRoute(
onToggleChatNotifications = viewModel::onToggleChatNotifications, onToggleChatNotifications = viewModel::onToggleChatNotifications,
onClearHistory = viewModel::onClearHistory, onClearHistory = viewModel::onClearHistory,
onDeleteChat = viewModel::onDeleteOrLeaveChat, onDeleteChat = viewModel::onDeleteOrLeaveChat,
onPromoteMember = viewModel::promoteMember,
onDemoteMember = viewModel::demoteMember,
onBanMember = viewModel::banMember,
onKickMember = viewModel::kickMember,
onTransferOwnership = viewModel::transferOwnership,
onUnbanMember = viewModel::unbanMember,
) )
} }
@@ -327,6 +336,12 @@ fun ChatScreen(
onToggleChatNotifications: () -> Unit, onToggleChatNotifications: () -> Unit,
onClearHistory: () -> Unit, onClearHistory: () -> Unit,
onDeleteChat: () -> Unit, onDeleteChat: () -> Unit,
onPromoteMember: (Long) -> Unit,
onDemoteMember: (Long) -> Unit,
onBanMember: (Long) -> Unit,
onKickMember: (Long) -> Unit,
onTransferOwnership: (Long) -> Unit,
onUnbanMember: (Long) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
@@ -361,14 +376,28 @@ fun ChatScreen(
} }
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 isPrivateChat = state.chatType.equals("private", ignoreCase = true)
val canShowMembersTab = state.chatType.equals("group", ignoreCase = true) ||
(state.chatType.equals("channel", ignoreCase = true) && state.canManageMembers)
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) } val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
val senderNameByUserId = remember(state.messages) { val senderNameByUserId = remember(state.messages, state.chatMembers) {
state.messages val fromMembers = state.chatMembers.associate { member ->
val resolved = member.name.ifBlank {
member.username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #${member.userId}"
}
member.userId to resolved
}
val fromMessages = state.messages
.mapNotNull { message -> .mapNotNull { message ->
val name = message.senderDisplayName?.trim().orEmpty() val name = message.senderDisplayName?.trim().orEmpty()
if (name.isBlank()) null else message.senderId to name val resolved = when {
name.isNotBlank() -> name
!message.senderUsername.isNullOrBlank() -> "@${message.senderUsername}"
else -> null
}
if (resolved.isNullOrBlank()) null else message.senderId to resolved
} }
.toMap() .toMap()
fromMembers + fromMessages
} }
val replyAuthorByMessageId = remember(state.messages, senderNameByUserId) { val replyAuthorByMessageId = remember(state.messages, senderNameByUserId) {
state.messages.associate { message -> state.messages.associate { message ->
@@ -429,6 +458,20 @@ fun ChatScreen(
lastVisibleIndex < lastIndex - 1 lastVisibleIndex < lastIndex - 1
} }
} }
LaunchedEffect(canShowMembersTab) {
if (!canShowMembersTab && chatInfoTab == ChatInfoTab.Members) {
chatInfoTab = ChatInfoTab.Media
}
}
val availableInfoTabs = remember(canShowMembersTab) {
buildList {
add(ChatInfoTab.Media)
add(ChatInfoTab.Files)
add(ChatInfoTab.Links)
add(ChatInfoTab.Voice)
if (canShowMembersTab) add(ChatInfoTab.Members)
}
}
val isLightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f val isLightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f
val chatTopBarColor = if (isLightTheme) { val chatTopBarColor = if (isLightTheme) {
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f) MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)
@@ -1237,7 +1280,7 @@ fun ChatScreen(
.horizontalScroll(rememberScrollState()), .horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
ChatInfoTab.entries.forEach { tab -> availableInfoTabs.forEach { tab ->
val selected = chatInfoTab == tab val selected = chatInfoTab == tab
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
@@ -1305,6 +1348,16 @@ fun ChatScreen(
forceCycleSpeedAudioSourceId = null forceCycleSpeedAudioSourceId = null
} }
}, },
members = state.chatMembers,
bans = state.chatBans,
canManageMembers = state.canManageMembers,
canTransferOwnership = state.chatRole.equals("owner", ignoreCase = true),
onPromoteMember = onPromoteMember,
onDemoteMember = onDemoteMember,
onBanMember = onBanMember,
onKickMember = onKickMember,
onTransferOwnership = onTransferOwnership,
onUnbanMember = onUnbanMember,
onEntryClick = { entry -> onEntryClick = { entry ->
when (entry.type) { when (entry.type) {
ChatInfoEntryType.Media -> { ChatInfoEntryType.Media -> {
@@ -1343,6 +1396,7 @@ fun ChatScreen(
"${stringResource(id = R.string.chat_reply_to)} ${ "${stringResource(id = R.string.chat_reply_to)} ${
state.replyToMessage?.senderDisplayName state.replyToMessage?.senderDisplayName
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: state.replyToMessage?.senderUsername?.takeIf { it.isNotBlank() }?.let { "@$it" }
?: state.replyToMessage?.senderId?.let { senderNameByUserId[it] } ?: state.replyToMessage?.senderId?.let { senderNameByUserId[it] }
?: stringResource(id = R.string.common_unknown_user) ?: stringResource(id = R.string.common_unknown_user)
}" }"
@@ -2112,6 +2166,7 @@ private fun MessageBubble(
} }
val senderName = message.senderDisplayName val senderName = message.senderDisplayName
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?: message.senderUsername?.takeIf { it.isNotBlank() }?.let { "@$it" }
?: senderNameByUserId[message.senderId] ?: senderNameByUserId[message.senderId]
val legacyTextUrl = message.text?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) } val legacyTextUrl = message.text?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) }
val hasLegacyStickerImage = message.attachments.isEmpty() && val hasLegacyStickerImage = message.attachments.isEmpty() &&
@@ -2191,8 +2246,21 @@ private fun MessageBubble(
} }
if (message.replyToMessageId != null) { if (message.replyToMessageId != null) {
val replyAuthor = message.replyPreviewSenderName val replyAuthorFromPreview = message.replyPreviewSenderName
?.takeIf { it.isNotBlank() } ?.takeIf { it.isNotBlank() }
?.let { preview ->
val legacyUserId = Regex("""User\s*#(\d+)""", RegexOption.IGNORE_CASE)
.find(preview)
?.groupValues
?.getOrNull(1)
?.toLongOrNull()
if (legacyUserId != null) {
senderNameByUserId[legacyUserId] ?: preview
} else {
preview
}
}
val replyAuthor = replyAuthorFromPreview
?: replyAuthorByMessageId[message.replyToMessageId] ?: replyAuthorByMessageId[message.replyToMessageId]
?: "Unknown user" ?: "Unknown user"
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]" val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
@@ -3364,6 +3432,7 @@ private enum class ChatInfoTab(val title: String) {
Files("Files"), Files("Files"),
Links("Links"), Links("Links"),
Voice("Voice"), Voice("Voice"),
Members("Members"),
} }
private enum class ChatInfoEntryType { private enum class ChatInfoEntryType {
@@ -3396,8 +3465,34 @@ private fun ChatInfoTabContent(
onForceStopAudioSourceHandled: (String) -> Unit, onForceStopAudioSourceHandled: (String) -> Unit,
onForceToggleAudioSourceHandled: (String) -> Unit, onForceToggleAudioSourceHandled: (String) -> Unit,
onForceCycleSpeedAudioSourceHandled: (String) -> Unit, onForceCycleSpeedAudioSourceHandled: (String) -> Unit,
members: List<ChatMemberItem>,
bans: List<ChatBanItem>,
canManageMembers: Boolean,
canTransferOwnership: Boolean,
onPromoteMember: (Long) -> Unit,
onDemoteMember: (Long) -> Unit,
onBanMember: (Long) -> Unit,
onKickMember: (Long) -> Unit,
onTransferOwnership: (Long) -> Unit,
onUnbanMember: (Long) -> Unit,
onEntryClick: (ChatInfoEntry) -> Unit, onEntryClick: (ChatInfoEntry) -> Unit,
) { ) {
if (tab == ChatInfoTab.Members) {
ChatMembersTabContent(
members = members,
bans = bans,
canManageMembers = canManageMembers,
canTransferOwnership = canTransferOwnership,
onPromoteMember = onPromoteMember,
onDemoteMember = onDemoteMember,
onBanMember = onBanMember,
onKickMember = onKickMember,
onTransferOwnership = onTransferOwnership,
onUnbanMember = onUnbanMember,
)
return
}
val filtered = remember(tab, entries) { val filtered = remember(tab, entries) {
entries.filter { entries.filter {
when (tab) { when (tab) {
@@ -3405,6 +3500,7 @@ private fun ChatInfoTabContent(
ChatInfoTab.Files -> it.type == ChatInfoEntryType.File ChatInfoTab.Files -> it.type == ChatInfoEntryType.File
ChatInfoTab.Links -> it.type == ChatInfoEntryType.Link ChatInfoTab.Links -> it.type == ChatInfoEntryType.Link
ChatInfoTab.Voice -> it.type == ChatInfoEntryType.Voice ChatInfoTab.Voice -> it.type == ChatInfoEntryType.Voice
ChatInfoTab.Members -> false
} }
} }
} }
@@ -3625,6 +3721,158 @@ private fun ChatInfoTabContent(
private val urlRegex = Regex("""https?://[^\s]+""") private val urlRegex = Regex("""https?://[^\s]+""")
@Composable
private fun ChatMembersTabContent(
members: List<ChatMemberItem>,
bans: List<ChatBanItem>,
canManageMembers: Boolean,
canTransferOwnership: Boolean,
onPromoteMember: (Long) -> Unit,
onDemoteMember: (Long) -> Unit,
onBanMember: (Long) -> Unit,
onKickMember: (Long) -> Unit,
onTransferOwnership: (Long) -> Unit,
onUnbanMember: (Long) -> Unit,
) {
if (members.isEmpty() && bans.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 20.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No members data",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
return
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.height(320.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (members.isNotEmpty()) {
item {
Text(
text = "Members (${members.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(members, key = { it.userId }) { member ->
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.55f))
.padding(horizontal = 10.dp, vertical = 9.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = member.name.ifBlank { member.username?.let { "@$it" } ?: "User #${member.userId}" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "${member.role} • id ${member.userId}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (canManageMembers && !member.role.equals("owner", ignoreCase = true)) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (member.role.equals("member", ignoreCase = true)) {
AssistChip(
onClick = { onPromoteMember(member.userId) },
label = { Text("Promote") },
)
}
if (member.role.equals("admin", ignoreCase = true)) {
AssistChip(
onClick = { onDemoteMember(member.userId) },
label = { Text("Demote") },
)
}
if (canTransferOwnership) {
AssistChip(
onClick = { onTransferOwnership(member.userId) },
label = { Text("Transfer owner") },
)
}
AssistChip(
onClick = { onBanMember(member.userId) },
label = { Text("Ban") },
)
AssistChip(
onClick = { onKickMember(member.userId) },
label = { Text("Kick") },
)
}
}
}
}
}
if (bans.isNotEmpty()) {
item {
Text(
text = "Banned (${bans.size})",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
items(bans, key = { it.userId }) { ban ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.55f))
.padding(horizontal = 10.dp, vertical = 9.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = ban.name.ifBlank { ban.username?.let { "@$it" } ?: "User #${ban.userId}" },
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = "id ${ban.userId}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (canManageMembers) {
AssistChip(
onClick = { onUnbanMember(ban.userId) },
label = { Text("Unban") },
)
}
}
}
}
}
}
private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntry> { private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntry> {
val entries = mutableListOf<ChatInfoEntry>() val entries = mutableListOf<ChatInfoEntry>()
messages messages
@@ -3674,7 +3922,7 @@ private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntr
entries += ChatInfoEntry( entries += ChatInfoEntry(
type = ChatInfoEntryType.Link, type = ChatInfoEntryType.Link,
title = match.value, title = match.value,
subtitle = "${message.senderDisplayName ?: "Unknown"}$time", subtitle = "${message.senderDisplayName?.takeIf { it.isNotBlank() } ?: message.senderUsername?.let { "@$it" } ?: "Unknown"}$time",
resourceUrl = match.value, resourceUrl = match.value,
sourceMessageId = message.id, sourceMessageId = message.id,
) )

View File

@@ -73,6 +73,7 @@ class ChatViewModel @Inject constructor(
private var lastDeliveredMessageId: Long? = null private var lastDeliveredMessageId: Long? = null
private var lastReadMessageId: Long? = null private var lastReadMessageId: Long? = null
private val reactionsRequestedMessageIds = mutableSetOf<Long>() private val reactionsRequestedMessageIds = mutableSetOf<Long>()
private var membersLoadKey: String? = null
init { init {
activeChatTracker.setActiveChat(chatId) activeChatTracker.setActiveChat(chatId)
@@ -504,6 +505,45 @@ class ChatViewModel @Inject constructor(
} }
} }
fun promoteMember(userId: Long) {
updateMemberRole(userId = userId, role = "admin")
}
fun demoteMember(userId: Long) {
updateMemberRole(userId = userId, role = "member")
}
fun transferOwnership(userId: Long) {
updateMemberRole(userId = userId, role = "owner")
}
fun kickMember(userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun banMember(userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun unbanMember(userId: Long) {
viewModelScope.launch {
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onSendClick() { fun onSendClick() {
val text = uiState.value.inputText.trim() val text = uiState.value.inputText.trim()
if (text.isBlank()) return if (text.isBlank()) return
@@ -712,16 +752,54 @@ class ChatViewModel @Inject constructor(
} }
it.copy( it.copy(
chatType = chat.type, chatType = chat.type,
chatRole = role,
chatTitle = chatTitle, chatTitle = chatTitle,
chatSubtitle = chatSubtitle, chatSubtitle = chatSubtitle,
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl, chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
chatUnreadCount = chat.unreadCount.coerceAtLeast(0), chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
canManageMembers = role == "owner" || role == "admin",
canSendMessages = canSend, canSendMessages = canSend,
sendRestrictionText = restriction, sendRestrictionText = restriction,
pinnedMessageId = chat.pinnedMessageId, pinnedMessageId = chat.pinnedMessageId,
pinnedMessage = pinnedMessage, pinnedMessage = pinnedMessage,
) )
} }
val shouldLoadMembers = chat.type.equals("group", ignoreCase = true) ||
(chat.type.equals("channel", ignoreCase = true) && (role == "owner" || role == "admin"))
val nextLoadKey = "${chat.id}:${chat.type.lowercase()}:${role ?: "none"}"
if (shouldLoadMembers && membersLoadKey != nextLoadKey) {
membersLoadKey = nextLoadKey
refreshMembersAndBans()
}
}
}
}
private fun updateMemberRole(userId: Long, role: String) {
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
is AppResult.Success -> refreshMembersAndBans()
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
private fun refreshMembersAndBans() {
viewModelScope.launch {
val membersResult = chatRepository.listMembers(chatId = chatId)
val bansResult = chatRepository.listBans(chatId = chatId)
_uiState.update {
it.copy(
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
errorMessage = listOf(membersResult, bansResult)
.filterIsInstance<AppResult.Error>()
.firstOrNull()
?.reason
?.toUiMessage()
?: it.errorMessage,
)
} }
} }
} }

View File

@@ -2,6 +2,8 @@ package ru.daemonlord.messenger.ui.chat
import ru.daemonlord.messenger.domain.message.model.MessageItem import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.message.model.MessageReaction import ru.daemonlord.messenger.domain.message.model.MessageReaction
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
data class MessageUiState( data class MessageUiState(
val chatId: Long = 0L, val chatId: Long = 0L,
@@ -14,7 +16,11 @@ data class MessageUiState(
val chatSubtitle: String = "", val chatSubtitle: String = "",
val chatAvatarUrl: String? = null, val chatAvatarUrl: String? = null,
val chatType: String = "", val chatType: String = "",
val chatRole: String? = null,
val chatUnreadCount: Int = 0, val chatUnreadCount: Int = 0,
val chatMembers: List<ChatMemberItem> = emptyList(),
val chatBans: List<ChatBanItem> = emptyList(),
val canManageMembers: Boolean = false,
val messages: List<MessageItem> = emptyList(), val messages: List<MessageItem> = emptyList(),
val pinnedMessageId: Long? = null, val pinnedMessageId: Long? = null,
val pinnedMessage: MessageItem? = null, val pinnedMessage: MessageItem? = null,

View File

@@ -61,6 +61,7 @@ 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.res.stringResource
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
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
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -83,10 +84,10 @@ 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.R
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
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
@@ -151,6 +152,7 @@ fun ChatListRoute(
onRemoveMember = viewModel::removeMember, onRemoveMember = viewModel::removeMember,
onBanMember = viewModel::banMember, onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember, onUnbanMember = viewModel::unbanMember,
onToggleDayNightMode = viewModel::toggleDayNightMode,
) )
} }
@@ -191,6 +193,7 @@ fun ChatListScreen(
onRemoveMember: (Long, Long) -> Unit, onRemoveMember: (Long, Long) -> Unit,
onBanMember: (Long, Long) -> Unit, onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit, onUnbanMember: (Long, Long) -> Unit,
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
var managementExpanded by remember { mutableStateOf(false) } var managementExpanded by remember { mutableStateOf(false) }
@@ -411,9 +414,8 @@ fun ChatListScreen(
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { text = {
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
Text( Text(
if (isNight) { if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
stringResource(id = R.string.menu_day_mode) stringResource(id = R.string.menu_day_mode)
} else { } else {
stringResource(id = R.string.menu_night_mode) stringResource(id = R.string.menu_night_mode)
@@ -423,13 +425,13 @@ fun ChatListScreen(
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) }, leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
onClick = { onClick = {
showDefaultMenu = false showDefaultMenu = false
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES onToggleDayNightMode { nextMode ->
if (isNight) { val toastRes = when (nextMode) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) AppThemeMode.LIGHT -> R.string.toast_day_mode_enabled
Toast.makeText(context, context.getString(R.string.toast_day_mode_enabled), Toast.LENGTH_SHORT).show() AppThemeMode.DARK -> R.string.toast_night_mode_enabled
} else { AppThemeMode.SYSTEM -> R.string.toast_day_mode_enabled
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) }
Toast.makeText(context, context.getString(R.string.toast_night_mode_enabled), Toast.LENGTH_SHORT).show() Toast.makeText(context, context.getString(toastRes), Toast.LENGTH_SHORT).show()
} }
}, },
) )

View File

@@ -1,5 +1,6 @@
package ru.daemonlord.messenger.ui.chats package ru.daemonlord.messenger.ui.chats
import androidx.appcompat.app.AppCompatDelegate
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
@@ -24,6 +25,8 @@ import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.domain.search.repository.SearchRepository import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import javax.inject.Inject import javax.inject.Inject
@@ -38,6 +41,7 @@ class ChatListViewModel @Inject constructor(
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val chatSearchRepository: ChatSearchRepository, private val chatSearchRepository: ChatSearchRepository,
private val searchRepository: SearchRepository, private val searchRepository: SearchRepository,
private val themeRepository: ThemeRepository,
) : ViewModel() { ) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -170,6 +174,29 @@ class ChatListViewModel @Inject constructor(
} }
} }
fun toggleDayNightMode(onResult: (AppThemeMode) -> Unit = {}) {
viewModelScope.launch {
val current = themeRepository.getThemeMode()
val next = when (current) {
AppThemeMode.DARK -> AppThemeMode.LIGHT
AppThemeMode.LIGHT -> AppThemeMode.DARK
AppThemeMode.SYSTEM -> {
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
if (isNight) AppThemeMode.LIGHT else AppThemeMode.DARK
}
}
themeRepository.setThemeMode(next)
AppCompatDelegate.setDefaultNightMode(
when (next) {
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
)
onResult(next)
}
}
fun onManagementChatSelected(chatId: Long?) { fun onManagementChatSelected(chatId: Long?) {
_uiState.update { it.copy(selectedManageChatId = chatId) } _uiState.update { it.copy(selectedManageChatId = chatId) }
if (chatId != null) { if (chatId != null) {