android: add contacts API parity and real contacts screen
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -31,3 +31,8 @@ data class UserSearchDto(
|
||||
@SerialName("avatar_url")
|
||||
val avatarUrl: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AddContactByEmailRequestDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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,6 +106,11 @@ private fun ContactsScreen(
|
||||
TopAppBar(
|
||||
title = { Text("Contacts") },
|
||||
)
|
||||
PullToRefreshBox(
|
||||
isRefreshing = state.isRefreshing,
|
||||
onRefresh = onRefresh,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -100,29 +118,135 @@ private fun ContactsScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
value = state.query,
|
||||
onValueChange = onQueryChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(filtered) { name ->
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(text = name, fontWeight = FontWeight.SemiBold)
|
||||
if (state.isSearchingUsers || state.query.trim().length >= 2) {
|
||||
item(key = "search_header") {
|
||||
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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user