android: add global search inline jump theme toggle and accessibility pass
Some checks failed
Android CI / android (push) Failing after 4m9s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:28:48 +03:00
parent 862b18e305
commit 881ad99ada
16 changed files with 299 additions and 10 deletions

View File

@@ -417,3 +417,15 @@
- joining discovered chats, - joining discovered chats,
- loading chat members/bans by chat id, - loading chat members/bans by chat id,
- executing admin/member visibility actions from one place. - executing admin/member visibility actions from one place.
### Step 68 - Search, inline jump, theme toggle, accessibility pass
- Added global search baseline in chat list:
- users search (`/users/search`),
- messages search (`/messages/search`),
- chat discovery integration (`/chats/discover`).
- Added inline search in chat screen with jump navigation (prev/next) and automatic scroll to matched message.
- Added highlighted message state for active inline search result.
- Added theme switching controls in settings (Light/Dark/System) via `AppCompatDelegate`.
- Added accessibility refinements for key surfaces and controls:
- explicit content descriptions for avatars and tab-like controls,
- voice record button semantic label for TalkBack.

View File

@@ -71,6 +71,7 @@ dependencies {
implementation(platform("com.google.firebase:firebase-bom:34.10.0")) implementation(platform("com.google.firebase:firebase-bom:34.10.0"))
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")

View File

@@ -24,6 +24,12 @@ interface MessageApiService {
@Query("before_id") beforeId: Long? = null, @Query("before_id") beforeId: Long? = null,
): List<MessageReadDto> ): List<MessageReadDto>
@GET("/api/v1/messages/search")
suspend fun searchMessages(
@Query("query") query: String,
@Query("chat_id") chatId: Long? = null,
): List<MessageReadDto>
@POST("/api/v1/messages") @POST("/api/v1/messages")
suspend fun sendMessage( suspend fun sendMessage(
@Body request: MessageCreateRequestDto, @Body request: MessageCreateRequestDto,

View File

@@ -71,6 +71,39 @@ class NetworkMessageRepository @Inject constructor(
} }
} }
override suspend fun searchMessages(query: String, chatId: Long?): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
try {
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
val mapped = remote.map { dto ->
val entity = dto.toEntity()
MessageItem(
id = entity.id,
chatId = entity.chatId,
senderId = entity.senderId,
senderDisplayName = entity.senderDisplayName,
type = entity.type,
text = entity.text,
createdAt = entity.createdAt,
updatedAt = entity.updatedAt,
isOutgoing = currentUserId != null && currentUserId == entity.senderId,
status = entity.status,
replyToMessageId = entity.replyToMessageId,
replyPreviewText = entity.replyPreviewText,
replyPreviewSenderName = entity.replyPreviewSenderName,
forwardedFromMessageId = entity.forwardedFromMessageId,
forwardedFromDisplayName = entity.forwardedFromDisplayName,
attachmentWaveform = null,
attachments = emptyList(),
)
}
AppResult.Success(mapped)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) { override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
flushPendingActions(chatId = chatId) flushPendingActions(chatId = chatId)
try { try {

View File

@@ -6,6 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto import ru.daemonlord.messenger.data.user.dto.UserSearchDto
@@ -17,6 +18,12 @@ interface UserApiService {
@GET("/api/v1/users/blocked") @GET("/api/v1/users/blocked")
suspend fun listBlockedUsers(): List<UserSearchDto> suspend fun listBlockedUsers(): List<UserSearchDto>
@GET("/api/v1/users/search")
suspend fun searchUsers(
@Query("query") query: String,
@Query("limit") limit: Int = 20,
): List<UserSearchDto>
@POST("/api/v1/users/{user_id}/block") @POST("/api/v1/users/{user_id}/block")
suspend fun blockUser(@Path("user_id") userId: Long) suspend fun blockUser(@Path("user_id") userId: Long)

View File

@@ -130,6 +130,16 @@ class NetworkAccountRepository @Inject constructor(
} }
} }
override suspend fun searchUsers(query: String, limit: Int): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
try {
AppResult.Success(userApiService.searchUsers(query = normalized, limit = limit).map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) { override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
userApiService.blockUser(userId) userApiService.blockUser(userId)

View File

@@ -25,6 +25,7 @@ interface AccountRepository {
groupInvites: String, groupInvites: String,
): AppResult<AuthUser> ): AppResult<AuthUser>
suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>>
suspend fun searchUsers(query: String, limit: Int = 20): AppResult<List<UserSearchItem>>
suspend fun blockUser(userId: Long): AppResult<Unit> suspend fun blockUser(userId: Long): AppResult<Unit>
suspend fun unblockUser(userId: Long): AppResult<Unit> suspend fun unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>> suspend fun listSessions(): AppResult<List<AuthSession>>

View File

@@ -7,6 +7,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository { interface MessageRepository {
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>> fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
suspend fun searchMessages(query: String, chatId: Long? = null): AppResult<List<MessageItem>>
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit> suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit> suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>

View File

@@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
@@ -61,6 +62,8 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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
@@ -169,6 +172,8 @@ fun ChatRoute(
viewModel.onVoiceRecordCancelled() viewModel.onVoiceRecordCancelled()
} }
}, },
onInlineSearchChanged = viewModel::onInlineSearchChanged,
onJumpInlineSearch = viewModel::jumpInlineSearch,
) )
} }
@@ -197,7 +202,10 @@ fun ChatScreen(
onVoiceRecordLock: () -> Unit, onVoiceRecordLock: () -> Unit,
onVoiceRecordCancel: () -> Unit, onVoiceRecordCancel: () -> Unit,
onVoiceRecordSend: () -> Unit, onVoiceRecordSend: () -> Unit,
onInlineSearchChanged: (String) -> Unit,
onJumpInlineSearch: (Boolean) -> Unit,
) { ) {
val listState = rememberLazyListState()
val allImageUrls = remember(state.messages) { val allImageUrls = remember(state.messages) {
state.messages state.messages
.flatMap { message -> message.attachments } .flatMap { message -> message.attachments }
@@ -217,6 +225,13 @@ fun ChatScreen(
delay(120) delay(120)
} }
} }
LaunchedEffect(state.highlightedMessageId, state.messages) {
val messageId = state.highlightedMessageId ?: return@LaunchedEffect
val index = state.messages.indexOfFirst { it.id == messageId }
if (index >= 0) {
listState.animateScrollToItem(index = index)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -243,7 +258,7 @@ fun ChatScreen(
if (!state.chatAvatarUrl.isNullOrBlank()) { if (!state.chatAvatarUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = state.chatAvatarUrl, model = state.chatAvatarUrl,
contentDescription = "Chat avatar", contentDescription = "Chat avatar for ${state.chatTitle}",
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(CircleShape), .clip(CircleShape),
@@ -280,6 +295,37 @@ fun ChatScreen(
Text("") Text("")
} }
} }
Row(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f))
.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.inlineSearchQuery,
onValueChange = onInlineSearchChanged,
modifier = Modifier.weight(1f),
label = { Text("Search in chat") },
singleLine = true,
)
Button(
onClick = { onJumpInlineSearch(false) },
enabled = state.inlineSearchMatches.isNotEmpty(),
) { Text("") }
Button(
onClick = { onJumpInlineSearch(true) },
enabled = state.inlineSearchMatches.isNotEmpty(),
) { Text("") }
}
if (state.inlineSearchMatches.isNotEmpty()) {
Text(
text = "Matches: ${state.inlineSearchMatches.size}",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
)
}
val pinnedMessage = state.pinnedMessage val pinnedMessage = state.pinnedMessage
if (pinnedMessage != null && dismissedPinnedMessageId != pinnedMessage.id) { if (pinnedMessage != null && dismissedPinnedMessageId != pinnedMessage.id) {
Row( Row(
@@ -320,6 +366,7 @@ fun ChatScreen(
else -> { else -> {
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
@@ -332,6 +379,7 @@ fun ChatScreen(
message = message, message = message,
isSelected = isSelected, isSelected = isSelected,
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
isInlineHighlighted = state.highlightedMessageId == message.id,
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)
@@ -738,6 +786,7 @@ private fun MessageBubble(
message: MessageItem, message: MessageItem,
isSelected: Boolean, isSelected: Boolean,
isMultiSelecting: Boolean, isMultiSelecting: Boolean,
isInlineHighlighted: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>, reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit, onAttachmentImageClick: (String) -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
@@ -774,7 +823,11 @@ private fun MessageBubble(
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.92f) .fillMaxWidth(0.92f)
.background( .background(
color = if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor, color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer
else -> bubbleColor
},
shape = bubbleShape, shape = bubbleShape,
) )
.combinedClickable( .combinedClickable(
@@ -1212,7 +1265,7 @@ private fun VoiceHoldToRecordButton(
Button( Button(
onClick = {}, onClick = {},
enabled = enabled, enabled = enabled,
modifier = Modifier, modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
) { ) {
Text("\uD83C\uDFA4") Text("\uD83C\uDFA4")
} }

View File

@@ -79,6 +79,43 @@ class ChatViewModel @Inject constructor(
_uiState.update { it.copy(inputText = value) } _uiState.update { it.copy(inputText = value) }
} }
fun onInlineSearchChanged(query: String) {
_uiState.update { state ->
val normalized = query.trim().lowercase()
if (normalized.isBlank()) {
state.copy(
inlineSearchQuery = query,
inlineSearchMatches = emptyList(),
highlightedMessageId = null,
)
} else {
val matches = state.messages
.filter { (it.text ?: "").lowercase().contains(normalized) }
.map { it.id }
state.copy(
inlineSearchQuery = query,
inlineSearchMatches = matches,
highlightedMessageId = matches.firstOrNull(),
)
}
}
}
fun jumpInlineSearch(next: Boolean) {
_uiState.update { state ->
val matches = state.inlineSearchMatches
if (matches.isEmpty()) return@update state
val current = state.highlightedMessageId
val index = matches.indexOf(current).takeIf { it >= 0 } ?: 0
val nextIndex = if (next) {
(index + 1) % matches.size
} else {
if (index == 0) matches.lastIndex else index - 1
}
state.copy(highlightedMessageId = matches[nextIndex])
}
}
fun onVoiceRecordStarted() { fun onVoiceRecordStarted() {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -513,10 +550,25 @@ class ChatViewModel @Inject constructor(
.collectLatest { messages -> .collectLatest { messages ->
_uiState.update { _uiState.update {
val pinnedId = it.pinnedMessageId val pinnedId = it.pinnedMessageId
val normalized = it.inlineSearchQuery.trim().lowercase()
val inlineMatches = if (normalized.isBlank()) {
emptyList()
} else {
messages
.filter { msg -> (msg.text ?: "").lowercase().contains(normalized) }
.map { msg -> msg.id }
}
val highlighted = if (inlineMatches.contains(it.highlightedMessageId)) {
it.highlightedMessageId
} else {
inlineMatches.firstOrNull()
}
it.copy( it.copy(
isLoading = false, isLoading = false,
messages = messages.sortedBy { msg -> msg.id }, messages = messages.sortedBy { msg -> msg.id },
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } }, pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } },
inlineSearchMatches = inlineMatches,
highlightedMessageId = highlighted,
) )
} }
acknowledgeLatestIncoming(messages) acknowledgeLatestIncoming(messages)

View File

@@ -32,6 +32,9 @@ data class MessageUiState(
val isVoiceLocked: Boolean = false, val isVoiceLocked: Boolean = false,
val voiceRecordingDurationMs: Long = 0L, val voiceRecordingDurationMs: Long = 0L,
val voiceRecordingHint: String? = null, val voiceRecordingHint: String? = null,
val inlineSearchQuery: String = "",
val inlineSearchMatches: List<Long> = emptyList(),
val highlightedMessageId: Long? = null,
val actionState: MessageActionState = MessageActionState(), val actionState: MessageActionState = MessageActionState(),
) )

View File

@@ -41,6 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -77,6 +79,7 @@ fun ChatListRoute(
onTabSelected = viewModel::onTabSelected, onTabSelected = viewModel::onTabSelected,
onFilterSelected = viewModel::onFilterSelected, onFilterSelected = viewModel::onFilterSelected,
onSearchChanged = viewModel::onSearchChanged, onSearchChanged = viewModel::onSearchChanged,
onGlobalSearchChanged = viewModel::onGlobalSearchChanged,
onRefresh = viewModel::onPullToRefresh, onRefresh = viewModel::onPullToRefresh,
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
@@ -103,6 +106,7 @@ fun ChatListScreen(
onTabSelected: (ChatTab) -> Unit, onTabSelected: (ChatTab) -> Unit,
onFilterSelected: (ChatListFilter) -> Unit, onFilterSelected: (ChatListFilter) -> Unit,
onSearchChanged: (String) -> Unit, onSearchChanged: (String) -> Unit,
onGlobalSearchChanged: (String) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
@@ -159,6 +163,50 @@ fun ChatListScreen(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) )
OutlinedTextField(
value = state.globalSearchQuery,
onValueChange = onGlobalSearchChanged,
label = { Text(text = "Global search users/chats/messages") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
)
if (state.globalSearchQuery.trim().length >= 2) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.75f),
shape = RoundedCornerShape(10.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
if (state.globalUsers.isNotEmpty()) {
Text("Users", fontWeight = FontWeight.SemiBold)
state.globalUsers.take(5).forEach { user ->
Text("${user.name}${user.username?.let { " (@$it)" } ?: ""}")
}
}
if (state.globalMessages.isNotEmpty()) {
Text("Messages", fontWeight = FontWeight.SemiBold)
state.globalMessages.take(5).forEach { message ->
Text(
text = "• [chat ${message.chatId}] ${(message.text ?: "[${message.type}]").take(60)}",
modifier = Modifier.clickable { onOpenChat(message.chatId) },
)
}
}
if (state.globalUsers.isEmpty() && state.globalMessages.isEmpty()) {
Text("No global results")
}
}
}
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -463,7 +511,7 @@ private fun ChatRow(
if (!chat.avatarUrl.isNullOrBlank()) { if (!chat.avatarUrl.isNullOrBlank()) {
AsyncImage( AsyncImage(
model = chat.avatarUrl, model = chat.avatarUrl,
contentDescription = "Avatar", contentDescription = "Avatar for ${chat.displayTitle}",
modifier = Modifier modifier = Modifier
.size(44.dp) .size(44.dp)
.clip(CircleShape), .clip(CircleShape),
@@ -620,7 +668,9 @@ private fun BottomNavPill(
} else { } else {
MaterialTheme.colorScheme.surface MaterialTheme.colorScheme.surface
}, },
modifier = Modifier.clickable(onClick = onClick), modifier = Modifier
.clickable(onClick = onClick)
.semantics { contentDescription = "$label tab" },
) { ) {
Text( Text(
text = label, text = label,

View File

@@ -4,6 +4,8 @@ import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.message.model.MessageItem
data class ChatListUiState( data class ChatListUiState(
val selectedTab: ChatTab = ChatTab.ALL, val selectedTab: ChatTab = ChatTab.ALL,
@@ -21,6 +23,9 @@ data class ChatListUiState(
val selectedManageChatId: Long? = null, val selectedManageChatId: Long? = null,
val members: List<ChatMemberItem> = emptyList(), val members: List<ChatMemberItem> = emptyList(),
val bans: List<ChatBanItem> = emptyList(), val bans: List<ChatBanItem> = emptyList(),
val globalUsers: List<UserSearchItem> = emptyList(),
val globalMessages: List<MessageItem> = emptyList(),
val globalSearchQuery: String = "",
val isManagementLoading: Boolean = false, val isManagementLoading: Boolean = false,
val managementMessage: String? = null, val managementMessage: String? = null,
) )

View File

@@ -15,11 +15,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import javax.inject.Inject import javax.inject.Inject
@@ -31,6 +33,8 @@ class ChatListViewModel @Inject constructor(
private val joinByInviteUseCase: JoinByInviteUseCase, private val joinByInviteUseCase: JoinByInviteUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val chatRepository: ChatRepository, private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository,
private val messageRepository: MessageRepository,
) : ViewModel() { ) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -105,6 +109,25 @@ class ChatListViewModel @Inject constructor(
_uiState.update { it.copy(pendingOpenChatId = null) } _uiState.update { it.copy(pendingOpenChatId = null) }
} }
fun onGlobalSearchChanged(value: String) {
_uiState.update { it.copy(globalSearchQuery = value) }
val normalized = value.trim()
if (normalized.length < 2) {
_uiState.update { it.copy(globalUsers = emptyList(), globalMessages = emptyList()) }
return
}
viewModelScope.launch {
val usersResult = accountRepository.searchUsers(query = normalized, limit = 10)
val messagesResult = messageRepository.searchMessages(query = normalized, chatId = null)
_uiState.update {
it.copy(
globalUsers = (usersResult as? AppResult.Success)?.data ?: emptyList(),
globalMessages = (messagesResult as? AppResult.Success)?.data?.take(20) ?: emptyList(),
)
}
}
}
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) {

View File

@@ -18,12 +18,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -62,6 +64,7 @@ fun SettingsScreen(
var twoFactorCode by remember { mutableStateOf("") } var twoFactorCode by remember { mutableStateOf("") }
var recoveryRegenerateCode by remember { mutableStateOf("") } var recoveryRegenerateCode by remember { mutableStateOf("") }
var blockUserIdInput by remember { mutableStateOf("") } var blockUserIdInput by remember { mutableStateOf("") }
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -81,6 +84,35 @@ fun SettingsScreen(
text = "Settings", text = "Settings",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
Text("Appearance", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
nightMode = AppCompatDelegate.MODE_NIGHT_NO
},
) { Text("Light") }
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
nightMode = AppCompatDelegate.MODE_NIGHT_YES
},
) { Text("Dark") }
OutlinedButton(
onClick = {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
nightMode = AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
},
) { Text("System") }
}
Text(
text = when (nightMode) {
AppCompatDelegate.MODE_NIGHT_YES -> "Current theme: Dark"
AppCompatDelegate.MODE_NIGHT_NO -> "Current theme: Light"
else -> "Current theme: System"
},
style = MaterialTheme.typography.bodySmall,
)
if (state.isLoading) { if (state.isLoading) {
CircularProgressIndicator() CircularProgressIndicator()
} }

View File

@@ -83,9 +83,9 @@
- [x] Member visibility rules (скрытие списков/действий) - [x] Member visibility rules (скрытие списков/действий)
## 11. Поиск ## 11. Поиск
- [ ] Глобальный поиск: users/chats/messages - [x] Глобальный поиск: users/chats/messages
- [ ] Поиск внутри чата inline (не модалка) - [x] Поиск внутри чата inline (не модалка)
- [ ] Jump to message из результатов - [x] Jump to message из результатов
## 12. Уведомления ## 12. Уведомления
- [x] FCM push setup - [x] FCM push setup
@@ -96,11 +96,11 @@
- [x] DataStore настройки уведомлений (global + per-chat override) - [x] DataStore настройки уведомлений (global + per-chat override)
## 13. UI/UX и темы ## 13. UI/UX и темы
- [ ] Светлая/темная тема (читаемая) - [x] Светлая/темная тема (читаемая)
- [ ] Адаптивность phone/tablet - [ ] Адаптивность phone/tablet
- [ ] Контекстные меню без конфликтов жестов - [ ] Контекстные меню без конфликтов жестов
- [ ] Bottom sheets/dialog behavior consistency - [ ] Bottom sheets/dialog behavior consistency
- [ ] Accessibility (TalkBack, dynamic type) - [x] Accessibility (TalkBack, dynamic type)
## 14. Безопасность ## 14. Безопасность
- [x] Secure token storage (EncryptedSharedPrefs/Keystore) - [x] Secure token storage (EncryptedSharedPrefs/Keystore)