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: - Extended message data layer with:
- `GET /api/v1/messages/{message_id}/thread` - `GET /api/v1/messages/{message_id}/thread`
- `MessageRepository.getMessageThread(...)` for thread/replies usage in upcoming UI steps. - `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.Path
import retrofit2.http.Query 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.AddContactByEmailRequestDto
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
@@ -29,4 +30,16 @@ interface UserApiService {
@DELETE("/api/v1/users/{user_id}/block") @DELETE("/api/v1/users/{user_id}/block")
suspend fun unblockUser(@Path("user_id") userId: Long) 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") @SerialName("avatar_url")
val avatarUrl: String? = null, 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.data.media.dto.UploadUrlRequestDto
import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService 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.UserProfileUpdateRequestDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto import ru.daemonlord.messenger.data.user.dto.UserSearchDto
import ru.daemonlord.messenger.di.IoDispatcher 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) { override suspend fun searchUsers(query: String, limit: Int): AppResult<List<UserSearchItem>> = withContext(ioDispatcher) {
val normalized = query.trim() val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList()) 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) { override suspend fun blockUser(userId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
userApiService.blockUser(userId) userApiService.blockUser(userId)

View File

@@ -25,7 +25,11 @@ interface AccountRepository {
groupInvites: String, groupInvites: String,
): AppResult<AuthUser> ): AppResult<AuthUser>
suspend fun listBlockedUsers(): AppResult<List<UserSearchItem>> 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 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 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>>

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.ui.contacts
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState 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.MaterialTheme
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.material3.TopAppBar 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.Composable
import androidx.compose.runtime.LaunchedEffect 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.getValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@Composable @Composable
fun ContactsRoute( fun ContactsRoute(
onMainBarVisibilityChanged: (Boolean) -> Unit, 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 @Composable
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun ContactsScreen( private fun ContactsScreen(
state: ContactsUiState,
onMainBarVisibilityChanged: (Boolean) -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
onQueryChanged: (String) -> Unit,
onAddByEmailChanged: (String) -> Unit,
onRefresh: () -> Unit,
onAddContact: (Long) -> Unit,
onAddContactByEmail: () -> Unit,
onRemoveContact: (Long) -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 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) { LaunchedEffect(Unit) {
onMainBarVisibilityChanged(true) onMainBarVisibilityChanged(true)
@@ -93,6 +106,11 @@ private fun ContactsScreen(
TopAppBar( TopAppBar(
title = { Text("Contacts") }, title = { Text("Contacts") },
) )
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -100,29 +118,135 @@ private fun ContactsScreen(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
OutlinedTextField( OutlinedTextField(
value = query, value = state.query,
onValueChange = { query = it }, onValueChange = onQueryChanged,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
label = { Text("Search contacts") }, 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,
) {
CircularProgressIndicator()
}
} else {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
items(filtered) { name -> if (state.isSearchingUsers || state.query.trim().length >= 2) {
Column( item(key = "search_header") {
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(text = name, fontWeight = FontWeight.SemiBold)
Text( Text(
text = "last seen recently", 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 ## 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) - `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` - `GET /api/v1/notifications`
- `POST /api/v1/auth/resend-verification` - `POST /api/v1/auth/resend-verification`
@@ -36,4 +31,4 @@ Backend покрывает web-функционал почти полность
Завершить следующий parity-блок: Завершить следующий parity-блок:
- `GET /api/v1/messages/{message_id}/thread` (UI usage) - `GET /api/v1/messages/{message_id}/thread` (UI usage)
- contacts API (`/users/contacts*`) + экран управления контактами - notifications API + UI inbox flow