Профиль, редактирование без аватара

This commit is contained in:
Халимов Рустам
2026-04-16 15:41:22 +03:00
parent 8409c51842
commit cea3f4d669
45 changed files with 1063 additions and 178 deletions
@@ -6,21 +6,57 @@ interface ContactApi {
@GET("contacts")
suspend fun getContacts(): List<ContactDto>
@POST("contacts/add")
suspend fun addContact(@Body userId: String): ContactDto
@POST("contacts/request")
suspend fun addContact(@Body request: AddContactRequest): Any
@DELETE("contacts/{userId}")
suspend fun removeContact(@Path("userId") userId: String)
@GET("users/search")
suspend fun searchUsers(@Query("query") query: String): List<ContactDto>
@GET("profiles/search")
suspend fun searchUsers(@Query("q") query: String): List<ContactDto>
@GET("contacts/requests")
suspend fun getFriendRequests(): List<FriendRequestDto>
@POST("contacts/{friendshipId}/accept")
suspend fun acceptRequest(@Path("friendshipId") friendshipId: String): Any
@POST("contacts/{friendshipId}/decline")
suspend fun declineRequest(@Path("friendshipId") friendshipId: String): Any
}
data class ContactDto(
data class FriendRequestDto(
val id: String,
val username: String,
val displayName: String?,
val avatarUrl: String?,
val isOnline: Boolean,
val lastSeen: String?
val user: ContactDto,
val createdAt: String,
val isOutgoing: Boolean
)
data class ContactDto(
@com.google.gson.annotations.SerializedName("id")
val id: String,
@com.google.gson.annotations.SerializedName("userName")
val userName: String?,
@com.google.gson.annotations.SerializedName("username")
val username: String?,
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
@com.google.gson.annotations.SerializedName("avatarUrl")
val avatarUrl: String?,
@com.google.gson.annotations.SerializedName("avatar")
val avatar: String?,
@com.google.gson.annotations.SerializedName("isOnline")
val isOnline: Boolean,
@com.google.gson.annotations.SerializedName("lastSeen")
val lastSeen: String?,
@com.google.gson.annotations.SerializedName("friendshipId")
val friendshipId: String? = null
) {
val effectiveUsername: String get() = username ?: userName ?: "unknown"
val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar
}
data class AddContactRequest(
@com.google.gson.annotations.SerializedName("contactId")
val contactId: String
)
@@ -0,0 +1,74 @@
package contacts.data.repository
import contacts.data.remote.api.ContactApi
import contacts.data.remote.api.ContactDto
import contacts.domain.repository.ContactRepository
import javax.inject.Inject
class ContactRepositoryImpl @Inject constructor(
private val api: ContactApi
) : ContactRepository {
override suspend fun getContacts(): Result<List<ContactDto>> {
return try {
Result.success(api.getContacts())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun addContact(userId: String): Result<ContactDto> {
return try {
val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId))
// Бэкенд возвращает статус или пустой ответ для запроса,
// так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или
// по-хорошему надо обновить доменную модель, но для начала вернем заглушку.
Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun removeContact(userId: String): Result<Unit> {
return try {
api.removeContact(userId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
return try {
Result.success(api.searchUsers(query))
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
return try {
Result.success(api.getFriendRequests())
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
return try {
api.acceptRequest(friendshipId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
return try {
api.declineRequest(friendshipId)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -0,0 +1,30 @@
package contacts.di
import contacts.data.remote.api.ContactApi
import contacts.data.repository.ContactRepositoryImpl
import contacts.domain.repository.ContactRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import retrofit2.Retrofit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ContactModule {
@Provides
@Singleton
fun provideContactApi(retrofit: Retrofit): ContactApi {
return retrofit.create(ContactApi::class.java)
}
@Provides
@Singleton
fun provideContactRepository(
api: ContactApi
): ContactRepository {
return ContactRepositoryImpl(api)
}
}
@@ -0,0 +1,13 @@
package contacts.domain.repository
import contacts.data.remote.api.ContactDto
interface ContactRepository {
suspend fun getContacts(): Result<List<ContactDto>>
suspend fun addContact(userId: String): Result<ContactDto>
suspend fun removeContact(userId: String): Result<Unit>
suspend fun searchUsers(query: String): Result<List<ContactDto>>
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
suspend fun acceptRequest(friendshipId: String): Result<Unit>
suspend fun declineRequest(friendshipId: String): Result<Unit>
}
@@ -6,10 +6,15 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import contacts.data.remote.api.ContactDto
@@ -20,10 +25,15 @@ import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactListScreen(
contacts: List<ContactDto>,
contacts: List<contacts.data.remote.api.ContactDto>,
requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
isLoading: Boolean = false,
onContactClick: (String) -> Unit,
onSearchChange: (String) -> Unit
onSearchChange: (String) -> Unit,
onAddContact: (String) -> Unit,
onStartChat: (String) -> Unit,
onAcceptRequest: (String) -> Unit,
onDeclineRequest: (String) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var selectedTab by remember { mutableIntStateOf(0) }
@@ -36,9 +46,10 @@ fun ContactListScreen(
topBar = {
Column {
TopAppBar(
title = { Text(stringResource(R.string.contacts_title)) },
title = { Text(stringResource(R.string.contacts_title), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
TabRow(selectedTabIndex = selectedTab) {
@@ -78,13 +89,57 @@ fun ContactListScreen(
CircularProgressIndicator()
}
} else {
val filteredContacts = contacts.filter {
if (selectedTab == 1) it.isOnline else true
val filteredContacts = if (searchQuery.isNotEmpty()) {
contacts
} else {
contacts.filter {
if (selectedTab == 1) it.isOnline else true
}
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(filteredContacts) { contact ->
ContactItem(contact = contact, onClick = { onContactClick(contact.id) })
// Contact Requests Section
if (searchQuery.isEmpty() && requests.isNotEmpty()) {
item {
Text(
text = "Заявки в контакты",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(requests) { request ->
ContactRequestItem(
request = request,
onAccept = { onAcceptRequest(request.id) },
onDecline = { onDeclineRequest(request.id) }
)
}
item {
Divider(modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant)
}
}
if (filteredContacts.isEmpty() && requests.isEmpty()) {
item {
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = if (searchQuery.isNotEmpty()) "Пользователи не найдены" else "Список контактов пуст",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
items(filteredContacts) { contact ->
ContactItem(
contact = contact,
onClick = { onContactClick(contact.id) },
onAddClick = { onAddContact(contact.id) },
onChatClick = { onStartChat(contact.id) },
isSearchMode = searchQuery.isNotEmpty()
)
}
}
}
}
@@ -93,7 +148,56 @@ fun ContactListScreen(
}
@Composable
fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
fun ContactRequestItem(
request: contacts.data.remote.api.FriendRequestDto,
onAccept: () -> Unit,
onDecline: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = request.user.effectiveAvatarUrl,
name = request.user.effectiveUsername,
size = 48.dp
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = request.user.displayName ?: request.user.effectiveUsername,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = if (request.isOutgoing) "Исходящий запрос" else "Входящий запрос",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
if (!request.isOutgoing) {
IconButton(onClick = onAccept) {
Icon(Icons.Default.CheckCircle, contentDescription = "Accept", tint = Color(0xFF4CAF50))
}
}
IconButton(onClick = onDecline) {
Icon(Icons.Default.Cancel, contentDescription = "Decline", tint = MaterialTheme.colorScheme.error)
}
}
}
}
@Composable
fun ContactItem(
contact: contacts.data.remote.api.ContactDto,
onClick: () -> Unit,
onAddClick: () -> Unit,
onChatClick: () -> Unit,
isSearchMode: Boolean
) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -102,8 +206,8 @@ fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
verticalAlignment = Alignment.CenterVertically
) {
AppAvatar(
url = contact.avatarUrl,
name = contact.username,
url = contact.effectiveAvatarUrl,
name = contact.effectiveUsername,
size = 56.dp
)
@@ -111,7 +215,7 @@ fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = contact.displayName ?: contact.username,
text = contact.displayName ?: contact.effectiveUsername,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface
)
@@ -126,5 +230,24 @@ fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Row {
if (isSearchMode) {
IconButton(onClick = onAddClick) {
Icon(
imageVector = Icons.Default.PersonAdd,
contentDescription = "Add Contact",
tint = MaterialTheme.colorScheme.primary
)
}
}
IconButton(onClick = onChatClick) {
Icon(
imageVector = Icons.Default.Chat,
contentDescription = "Start Chat",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
@@ -2,25 +2,121 @@ package contacts.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import contacts.data.remote.api.ContactDto
import chats.domain.repository.ChatRepository
import contacts.domain.repository.ContactRepository
import core.utils.NavigationManager
import dagger.hilt.android.lifecycle.HiltViewModel
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 javax.inject.Inject
data class ContactListState(
val contacts: List<ContactDto> = emptyList(),
val contacts: List<contacts.data.remote.api.ContactDto> = emptyList(),
val requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
@HiltViewModel
class ContactListViewModel @Inject constructor() : ViewModel() {
class ContactListViewModel @Inject constructor(
private val contactRepository: ContactRepository,
private val chatRepository: ChatRepository,
private val navigationManager: NavigationManager
) : ViewModel() {
private val _state = MutableStateFlow(ContactListState())
val state: StateFlow<ContactListState> = _state.asStateFlow()
init {
loadData()
}
fun loadData() {
loadContacts()
loadRequests()
}
fun loadContacts() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
contactRepository.getContacts()
.onSuccess { contacts ->
_state.update { it.copy(contacts = contacts, isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun loadRequests() {
viewModelScope.launch {
contactRepository.getFriendRequests()
.onSuccess { requests ->
_state.update { it.copy(requests = requests) }
}
}
}
fun onSearchChange(query: String) {
// Заглушка поиска
if (query.length < 3) {
if (query.isEmpty()) loadData()
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
contactRepository.searchUsers(query)
.onSuccess { users ->
_state.update { it.copy(contacts = users, isLoading = false) }
}
.onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) }
}
}
}
fun addContact(userId: String) {
viewModelScope.launch {
contactRepository.addContact(userId)
.onSuccess {
loadData()
}
.onFailure { e ->
_state.update { it.copy(error = e.message) }
}
}
}
fun acceptRequest(requestId: String) {
viewModelScope.launch {
contactRepository.acceptRequest(requestId)
.onSuccess { loadData() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
fun declineRequest(requestId: String) {
viewModelScope.launch {
contactRepository.declineRequest(requestId)
.onSuccess { loadData() }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
}
fun startChat(userId: String) {
viewModelScope.launch {
try {
// В вебе мы ищем существующий чат или создаем новый.
// В мобилке мы для начала можем просто вызвать createPersonalChat.
// Бэкенд обычно возвращает существующий чат, если он уже есть.
val chat = chatRepository.createPersonalChat(userId)
navigationManager.navigateToChat(chat.id)
} catch (e: Exception) {
_state.update { it.copy(error = e.message) }
}
}
}
}