diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 95e8af3..d45efb4 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f0dcc45..e6af27a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt index e4344f0..a5bb8a6 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt @@ -24,6 +24,12 @@ interface MessageApiService { @Query("before_id") beforeId: Long? = null, ): List + @GET("/api/v1/messages/search") + suspend fun searchMessages( + @Query("query") query: String, + @Query("chat_id") chatId: Long? = null, + ): List + @POST("/api/v1/messages") suspend fun sendMessage( @Body request: MessageCreateRequestDto, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index a5d58a4..9d1ced5 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -71,6 +71,39 @@ class NetworkMessageRepository @Inject constructor( } } + override suspend fun searchMessages(query: String, chatId: Long?): AppResult> = 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 = withContext(ioDispatcher) { flushPendingActions(chatId = chatId) try { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt index 5930bf2..3693e16 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt @@ -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 + @GET("/api/v1/users/search") + suspend fun searchUsers( + @Query("query") query: String, + @Query("limit") limit: Int = 20, + ): List + @POST("/api/v1/users/{user_id}/block") suspend fun blockUser(@Path("user_id") userId: Long) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt index fab4814..dca0066 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt @@ -130,6 +130,16 @@ class NetworkAccountRepository @Inject constructor( } } + override suspend fun searchUsers(query: String, limit: Int): AppResult> = 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 = withContext(ioDispatcher) { try { userApiService.blockUser(userId) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt index 2c86a5c..bcd0e90 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt @@ -25,6 +25,7 @@ interface AccountRepository { groupInvites: String, ): AppResult suspend fun listBlockedUsers(): AppResult> + suspend fun searchUsers(query: String, limit: Int = 20): AppResult> suspend fun blockUser(userId: Long): AppResult suspend fun unblockUser(userId: Long): AppResult suspend fun listSessions(): AppResult> diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index df836bd..d2b260e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -7,6 +7,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction interface MessageRepository { fun observeMessages(chatId: Long, limit: Int = 50): Flow> + suspend fun searchMessages(query: String, chatId: Long? = null): AppResult> suspend fun syncRecentMessages(chatId: Long): AppResult suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 93f8a8b..674155c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -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, 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") } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 73ff567..367b547 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -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) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 0e2b0c9..f5a1928 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -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 = emptyList(), + val highlightedMessageId: Long? = null, val actionState: MessageActionState = MessageActionState(), ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index 8bfb350..e1d556f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -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, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index 90df168..8c1147d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -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 = emptyList(), val bans: List = emptyList(), + val globalUsers: List = emptyList(), + val globalMessages: List = emptyList(), + val globalSearchQuery: String = "", val isManagementLoading: Boolean = false, val managementMessage: String? = null, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index f0748a7..21569e9 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -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) { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index b898b27..bc5d466 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -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() } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index fc145f4..cb59092 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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)