android: add global search inline jump theme toggle and accessibility pass
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user