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,
- loading chat members/bans by chat id,
- 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("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-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,
): 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")
suspend fun sendMessage(
@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) {
flushPendingActions(chatId = chatId)
try {

View File

@@ -6,6 +6,7 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
@@ -17,6 +18,12 @@ interface UserApiService {
@GET("/api/v1/users/blocked")
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")
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) {
try {
userApiService.blockUser(userId)

View File

@@ -25,6 +25,7 @@ interface AccountRepository {
groupInvites: String,
): AppResult<AuthUser>
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 unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>>

View File

@@ -7,6 +7,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository {
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 loadMoreMessages(chatId: Long, beforeMessageId: Long): 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.positionChange
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat
@@ -169,6 +172,8 @@ fun ChatRoute(
viewModel.onVoiceRecordCancelled()
}
},
onInlineSearchChanged = viewModel::onInlineSearchChanged,
onJumpInlineSearch = viewModel::jumpInlineSearch,
)
}
@@ -197,7 +202,10 @@ fun ChatScreen(
onVoiceRecordLock: () -> Unit,
onVoiceRecordCancel: () -> Unit,
onVoiceRecordSend: () -> Unit,
onInlineSearchChanged: (String) -> Unit,
onJumpInlineSearch: (Boolean) -> Unit,
) {
val listState = rememberLazyListState()
val allImageUrls = remember(state.messages) {
state.messages
.flatMap { message -> message.attachments }
@@ -217,6 +225,13 @@ fun ChatScreen(
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(
modifier = Modifier
.fillMaxSize()
@@ -243,7 +258,7 @@ fun ChatScreen(
if (!state.chatAvatarUrl.isNullOrBlank()) {
AsyncImage(
model = state.chatAvatarUrl,
contentDescription = "Chat avatar",
contentDescription = "Chat avatar for ${state.chatTitle}",
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
@@ -280,6 +295,37 @@ fun ChatScreen(
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
if (pinnedMessage != null && dismissedPinnedMessageId != pinnedMessage.id) {
Row(
@@ -320,6 +366,7 @@ fun ChatScreen(
else -> {
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
@@ -332,6 +379,7 @@ fun ChatScreen(
message = message,
isSelected = isSelected,
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
isInlineHighlighted = state.highlightedMessageId == message.id,
reactions = state.reactionByMessageId[message.id].orEmpty(),
onAttachmentImageClick = { imageUrl ->
val idx = allImageUrls.indexOf(imageUrl)
@@ -738,6 +786,7 @@ private fun MessageBubble(
message: MessageItem,
isSelected: Boolean,
isMultiSelecting: Boolean,
isInlineHighlighted: Boolean,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
onAttachmentImageClick: (String) -> Unit,
onClick: () -> Unit,
@@ -774,7 +823,11 @@ private fun MessageBubble(
modifier = Modifier
.fillMaxWidth(0.92f)
.background(
color = if (isSelected) MaterialTheme.colorScheme.tertiaryContainer else bubbleColor,
color = when {
isSelected -> MaterialTheme.colorScheme.tertiaryContainer
isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer
else -> bubbleColor
},
shape = bubbleShape,
)
.combinedClickable(
@@ -1212,7 +1265,7 @@ private fun VoiceHoldToRecordButton(
Button(
onClick = {},
enabled = enabled,
modifier = Modifier,
modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
) {
Text("\uD83C\uDFA4")
}

View File

@@ -79,6 +79,43 @@ class ChatViewModel @Inject constructor(
_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() {
_uiState.update {
it.copy(
@@ -513,10 +550,25 @@ class ChatViewModel @Inject constructor(
.collectLatest { messages ->
_uiState.update {
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(
isLoading = false,
messages = messages.sortedBy { msg -> msg.id },
pinnedMessage = pinnedId?.let { id -> messages.firstOrNull { msg -> msg.id == id } },
inlineSearchMatches = inlineMatches,
highlightedMessageId = highlighted,
)
}
acknowledgeLatestIncoming(messages)

View File

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

View File

@@ -41,6 +41,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape
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.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -77,6 +79,7 @@ fun ChatListRoute(
onTabSelected = viewModel::onTabSelected,
onFilterSelected = viewModel::onFilterSelected,
onSearchChanged = viewModel::onSearchChanged,
onGlobalSearchChanged = viewModel::onGlobalSearchChanged,
onRefresh = viewModel::onPullToRefresh,
onOpenSettings = onOpenSettings,
onOpenProfile = onOpenProfile,
@@ -103,6 +106,7 @@ fun ChatListScreen(
onTabSelected: (ChatTab) -> Unit,
onFilterSelected: (ChatListFilter) -> Unit,
onSearchChanged: (String) -> Unit,
onGlobalSearchChanged: (String) -> Unit,
onRefresh: () -> Unit,
onOpenSettings: () -> Unit,
onOpenProfile: () -> Unit,
@@ -159,6 +163,50 @@ fun ChatListScreen(
.fillMaxWidth()
.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(
modifier = Modifier
.fillMaxWidth()
@@ -463,7 +511,7 @@ private fun ChatRow(
if (!chat.avatarUrl.isNullOrBlank()) {
AsyncImage(
model = chat.avatarUrl,
contentDescription = "Avatar",
contentDescription = "Avatar for ${chat.displayTitle}",
modifier = Modifier
.size(44.dp)
.clip(CircleShape),
@@ -620,7 +668,9 @@ private fun BottomNavPill(
} else {
MaterialTheme.colorScheme.surface
},
modifier = Modifier.clickable(onClick = onClick),
modifier = Modifier
.clickable(onClick = onClick)
.semantics { contentDescription = "$label tab" },
) {
Text(
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.ChatMemberItem
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(
val selectedTab: ChatTab = ChatTab.ALL,
@@ -21,6 +23,9 @@ data class ChatListUiState(
val selectedManageChatId: Long? = null,
val members: List<ChatMemberItem> = emptyList(),
val bans: List<ChatBanItem> = emptyList(),
val globalUsers: List<UserSearchItem> = emptyList(),
val globalMessages: List<MessageItem> = emptyList(),
val globalSearchQuery: String = "",
val isManagementLoading: Boolean = false,
val managementMessage: String? = null,
)

View File

@@ -15,11 +15,13 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.model.ChatItem
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.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import javax.inject.Inject
@@ -31,6 +33,8 @@ class ChatListViewModel @Inject constructor(
private val joinByInviteUseCase: JoinByInviteUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository,
private val messageRepository: MessageRepository,
) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -105,6 +109,25 @@ class ChatListViewModel @Inject constructor(
_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?) {
_uiState.update { it.copy(selectedManageChatId = chatId) }
if (chatId != null) {

View File

@@ -18,12 +18,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -62,6 +64,7 @@ fun SettingsScreen(
var twoFactorCode by remember { mutableStateOf("") }
var recoveryRegenerateCode by remember { mutableStateOf("") }
var blockUserIdInput by remember { mutableStateOf("") }
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
val scrollState = rememberScrollState()
LaunchedEffect(Unit) {
@@ -81,6 +84,35 @@ fun SettingsScreen(
text = "Settings",
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) {
CircularProgressIndicator()
}

View File

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