diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index a2871b2..67f39d0 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt index 3693e16..526df5f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/api/UserApiService.kt @@ -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 + + @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) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt index dc7b11d..bb72905 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/dto/UserDtos.kt @@ -31,3 +31,8 @@ data class UserSearchDto( @SerialName("avatar_url") val avatarUrl: String? = null, ) + +@Serializable +data class AddContactByEmailRequestDto( + val email: String, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt index dca0066..828356b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt @@ -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> = 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> = 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 = withContext(ioDispatcher) { + try { + userApiService.addContact(userId = userId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + + override suspend fun addContactByEmail(email: String): AppResult = 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 = withContext(ioDispatcher) { + try { + userApiService.removeContact(userId = userId) + AppResult.Success(Unit) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + override suspend fun blockUser(userId: Long): AppResult = withContext(ioDispatcher) { try { userApiService.blockUser(userId) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt index bcd0e90..11e4e28 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt @@ -25,7 +25,11 @@ interface AccountRepository { groupInvites: String, ): AppResult suspend fun listBlockedUsers(): AppResult> + suspend fun listContacts(): AppResult> suspend fun searchUsers(query: String, limit: Int = 20): AppResult> + suspend fun addContact(userId: Long): AppResult + suspend fun addContactByEmail(email: String): AppResult + suspend fun removeContact(userId: Long): AppResult suspend fun blockUser(userId: Long): AppResult suspend fun unblockUser(userId: Long): AppResult suspend fun listSessions(): AppResult> diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt index a81ec22..b121d29 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsScreen.kt @@ -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, + ) + } + } } } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsUiState.kt new file mode 100644 index 0000000..7a208c2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsUiState.kt @@ -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 = emptyList(), + val searchResults: List = emptyList(), + val errorMessage: String? = null, + val infoMessage: String? = null, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsViewModel.kt new file mode 100644 index 0000000..8d1d4fc --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/contacts/ContactsViewModel.kt @@ -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 = _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." + } + } +} diff --git a/docs/backend-web-android-parity.md b/docs/backend-web-android-parity.md index cb0c79f..51c4c69 100644 --- a/docs/backend-web-android-parity.md +++ b/docs/backend-web-android-parity.md @@ -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