android: add contacts API parity and real contacts screen
Some checks failed
Android CI / android (push) Failing after 4m42s
Android Release / release (push) Failing after 5m38s
CI / test (push) Failing after 2m42s

This commit is contained in:
Codex
2026-03-09 22:54:13 +03:00
parent b294297dbd
commit e82178fcc3
9 changed files with 416 additions and 48 deletions

View File

@@ -665,3 +665,17 @@
- Extended message data layer with:
- `GET /api/v1/messages/{message_id}/thread`
- `MessageRepository.getMessageThread(...)` for thread/replies usage in upcoming UI steps.
### Step 103 - Contacts API parity + real Contacts screen
- Added Android integration for contacts endpoints:
- `GET /api/v1/users/contacts`
- `POST /api/v1/users/{user_id}/contacts`
- `POST /api/v1/users/contacts/by-email`
- `DELETE /api/v1/users/{user_id}/contacts`
- Extended `AccountRepository` + `NetworkAccountRepository` with contacts methods.
- Replaced placeholder Contacts screen with real stateful flow (`ContactsViewModel`):
- load contacts from backend,
- user search + add contact,
- add contact by email,
- remove contact,
- loading/refresh/error/info states.

View File

@@ -8,6 +8,7 @@ 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.AddContactByEmailRequestDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
@@ -29,4 +30,16 @@ interface UserApiService {
@DELETE("/api/v1/users/{user_id}/block")
suspend fun unblockUser(@Path("user_id") userId: Long)
@GET("/api/v1/users/contacts")
suspend fun listContacts(): List<UserSearchDto>
@POST("/api/v1/users/{user_id}/contacts")
suspend fun addContact(@Path("user_id") userId: Long)
@POST("/api/v1/users/contacts/by-email")
suspend fun addContactByEmail(@Body request: AddContactByEmailRequestDto)
@DELETE("/api/v1/users/{user_id}/contacts")
suspend fun removeContact(@Path("user_id") userId: Long)
}

View File

@@ -31,3 +31,8 @@ data class UserSearchDto(
@SerialName("avatar_url")
val avatarUrl: String? = null,
)
@Serializable
data class AddContactByEmailRequestDto(
val email: String,
)

View File

@@ -18,6 +18,7 @@ import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto
import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
import ru.daemonlord.messenger.di.IoDispatcher
@@ -130,6 +131,14 @@ class NetworkAccountRepository @Inject constructor(
}
}
override suspend fun listContacts(): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
try {
AppResult.Success(userApiService.listContacts().map { it.toDomain() })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
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())
@@ -140,6 +149,37 @@ class NetworkAccountRepository @Inject constructor(
}
}
override suspend fun addContact(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.addContact(userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun addContactByEmail(email: String): AppResult<Unit> = withContext(ioDispatcher) {
val normalized = email.trim()
if (normalized.isBlank()) return@withContext AppResult.Error(AppError.Server("Email is required"))
try {
userApiService.addContactByEmail(
request = AddContactByEmailRequestDto(email = normalized),
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun removeContact(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.removeContact(userId = userId)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
userApiService.blockUser(userId)

View File

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

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.contacts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -13,49 +14,61 @@ import androidx.compose.foundation.layout.windowInsetsPadding
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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
@Composable
fun ContactsRoute(
onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: ContactsViewModel = hiltViewModel(),
) {
ContactsScreen(onMainBarVisibilityChanged = onMainBarVisibilityChanged)
val state by viewModel.uiState.collectAsStateWithLifecycle()
ContactsScreen(
state = state,
onMainBarVisibilityChanged = onMainBarVisibilityChanged,
onQueryChanged = viewModel::onQueryChanged,
onAddByEmailChanged = viewModel::onAddByEmailChanged,
onRefresh = viewModel::onRefresh,
onAddContact = viewModel::addContact,
onAddContactByEmail = viewModel::addContactByEmail,
onRemoveContact = viewModel::removeContact,
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ContactsScreen(
state: ContactsUiState,
onMainBarVisibilityChanged: (Boolean) -> Unit,
onQueryChanged: (String) -> Unit,
onAddByEmailChanged: (String) -> Unit,
onRefresh: () -> Unit,
onAddContact: (Long) -> Unit,
onAddContactByEmail: () -> Unit,
onRemoveContact: (Long) -> Unit,
) {
val listState = rememberLazyListState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
var query by remember { mutableStateOf("") }
val contacts = remember {
listOf(
"Alex", "Marta", "Danya", "Ilya", "Mila", "Artem", "Nika", "Vlad",
"Sasha", "Roma", "Katya", "Nastya", "Boris", "Pavel", "Alyona",
)
}
val filtered = remember(query) {
if (query.isBlank()) contacts else contacts.filter { it.contains(query, ignoreCase = true) }
}
LaunchedEffect(Unit) {
onMainBarVisibilityChanged(true)
@@ -93,35 +106,146 @@ private fun ContactsScreen(
TopAppBar(
title = { Text("Contacts") },
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Search contacts") },
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(filtered) { name ->
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
OutlinedTextField(
value = state.query,
onValueChange = onQueryChanged,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text("Search contacts/users") },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = state.addByEmail,
onValueChange = onAddByEmailChanged,
modifier = Modifier.weight(1f),
singleLine = true,
label = { Text("Add by email") },
)
Button(onClick = onAddContactByEmail) {
Text("Add")
}
}
state.errorMessage?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
state.infoMessage?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
if (state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(text = name, fontWeight = FontWeight.SemiBold)
Text(
text = "last seen recently",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
CircularProgressIndicator()
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (state.isSearchingUsers || state.query.trim().length >= 2) {
item(key = "search_header") {
Text(
text = "Search results",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
items(state.searchResults, key = { "search_${it.id}" }) { user ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = user.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = user.username?.let { "@$it" } ?: "id: ${user.id}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { onAddContact(user.id) }) {
Text("Add")
}
}
}
}
item(key = "contacts_header") {
Text(
text = "My contacts",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
}
items(state.contacts, key = { "contact_${it.id}" }) { contact ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.name,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = contact.username?.let { "@$it" } ?: "last seen recently",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
OutlinedButton(onClick = { onRemoveContact(contact.id) }) {
Text("Remove")
}
}
}
if (state.contacts.isEmpty()) {
item(key = "empty_contacts") {
Text(
text = "No contacts yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.ui.contacts
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
data class ContactsUiState(
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val isSearchingUsers: Boolean = false,
val query: String = "",
val addByEmail: String = "",
val contacts: List<UserSearchItem> = emptyList(),
val searchResults: List<UserSearchItem> = emptyList(),
val errorMessage: String? = null,
val infoMessage: String? = null,
)

View File

@@ -0,0 +1,158 @@
package ru.daemonlord.messenger.ui.contacts
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
@HiltViewModel
class ContactsViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(ContactsUiState())
val uiState: StateFlow<ContactsUiState> = _uiState.asStateFlow()
private var searchJob: Job? = null
init {
loadContacts()
}
fun onQueryChanged(value: String) {
_uiState.update {
it.copy(
query = value,
errorMessage = null,
infoMessage = null,
)
}
val normalized = value.trim()
if (normalized.length < 2) {
searchJob?.cancel()
_uiState.update { it.copy(searchResults = emptyList(), isSearchingUsers = false) }
return
}
searchJob?.cancel()
searchJob = viewModelScope.launch {
_uiState.update { it.copy(isSearchingUsers = true) }
delay(250)
when (val result = accountRepository.searchUsers(query = normalized, limit = 20)) {
is AppResult.Success -> _uiState.update {
it.copy(
isSearchingUsers = false,
searchResults = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isSearchingUsers = false,
searchResults = emptyList(),
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
fun onAddByEmailChanged(value: String) {
_uiState.update { it.copy(addByEmail = value) }
}
fun onRefresh() {
loadContacts(forceRefresh = true)
}
fun addContact(userId: Long) {
viewModelScope.launch {
when (val result = accountRepository.addContact(userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(infoMessage = "Contact added.") }
loadContacts(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun addContactByEmail() {
val email = uiState.value.addByEmail.trim()
if (email.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email is required.") }
return
}
viewModelScope.launch {
when (val result = accountRepository.addContactByEmail(email = email)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
addByEmail = "",
infoMessage = "Contact added by email.",
)
}
loadContacts(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun removeContact(userId: Long) {
viewModelScope.launch {
when (val result = accountRepository.removeContact(userId = userId)) {
is AppResult.Success -> {
_uiState.update { it.copy(infoMessage = "Contact removed.") }
loadContacts(forceRefresh = true)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
private fun loadContacts(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.update {
it.copy(
isLoading = it.contacts.isEmpty(),
isRefreshing = forceRefresh,
errorMessage = null,
)
}
when (val result = accountRepository.listContacts()) {
is AppResult.Success -> _uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
contacts = result.data,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error."
AppError.Unauthorized -> "Session expired."
AppError.InvalidCredentials -> "Authorization error."
is AppError.Server -> this.message ?: "Server error."
is AppError.Unknown -> this.cause?.message ?: "Unknown error."
}
}
}

View File

@@ -18,11 +18,6 @@ Backend покрывает web-функционал почти полность
## 2) Web endpoints not yet fully used on Android
- `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending)
- Contacts endpoints:
- `GET /api/v1/users/contacts`
- `POST /api/v1/users/{user_id}/contacts`
- `POST /api/v1/users/contacts/by-email`
- `DELETE /api/v1/users/{user_id}/contacts`
- `GET /api/v1/notifications`
- `POST /api/v1/auth/resend-verification`
@@ -36,4 +31,4 @@ Backend покрывает web-функционал почти полность
Завершить следующий parity-блок:
- `GET /api/v1/messages/{message_id}/thread` (UI usage)
- contacts API (`/users/contacts*`) + экран управления контактами
- notifications API + UI inbox flow