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

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

View File

@@ -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
)
}
}
}
}

View File

@@ -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) }
}
}
}
}