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