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

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

@@ -74,4 +74,5 @@
<string name="chats">Чаты</string>
<string name="contacts_tab">Контакты</string>
<string name="profile_tab">Профиль</string>
<string name="saving">Сохранение...</string>
</resources>

View File

@@ -6,6 +6,9 @@ import auth.domain.model.AuthResult
import auth.domain.repository.AuthRepository
import core.network.ServerConfig
import core.security.TokenManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
class AuthRepositoryImpl @Inject constructor(
@@ -14,6 +17,8 @@ class AuthRepositoryImpl @Inject constructor(
private val serverConfig: ServerConfig
) : AuthRepository {
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
override suspend fun login(userName: String, password: String): Result<AuthResult> {
return try {
val response = api.login(AuthRequest(userName, password))
@@ -21,6 +26,7 @@ class AuthRepositoryImpl @Inject constructor(
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
@@ -43,6 +49,7 @@ class AuthRepositoryImpl @Inject constructor(
val userId = response.userId ?: ""
tokenManager.saveToken(token, userId)
_isAuthenticated.value = true
fetchConfig()
Result.success(
AuthResult(
@@ -60,10 +67,15 @@ class AuthRepositoryImpl @Inject constructor(
override suspend fun logout() {
tokenManager.deleteToken()
_isAuthenticated.value = false
}
override fun isAuthenticated(): Boolean {
return tokenManager.getToken() != null
return _isAuthenticated.value
}
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
return _isAuthenticated.asStateFlow()
}
override suspend fun fetchConfig(): Result<Unit> {

View File

@@ -8,5 +8,6 @@ interface AuthRepository {
suspend fun logout()
suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
suspend fun updatePushToken(token: String)
}

View File

@@ -23,15 +23,20 @@ class AuthViewModel @Inject constructor(
private val repository: AuthRepository
) : ViewModel() {
init {
if (repository.isAuthenticated()) {
updatePushToken()
}
}
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow()
init {
viewModelScope.launch {
repository.isAuthenticatedFlow().collect { authenticated ->
_state.update { it.copy(isAuthenticated = authenticated) }
if (authenticated) {
updatePushToken()
}
}
}
}
fun checkAuth() {
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
}

View File

@@ -54,6 +54,9 @@ interface ChatApi {
@POST("messages/{messageId}/reactions")
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
@POST("chats/personal")
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
@POST("chats/{chatId}/typing")
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
@@ -61,6 +64,10 @@ interface ChatApi {
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
}
data class CreatePersonalChatRequest(
val userId: String
)
data class KlipyResponse(
val data: KlipyDataWrapper
)

View File

@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -19,35 +20,74 @@ class SignalRNotificationObserver @Inject constructor(
private val signalrClient: ChatHubClient,
private val activeChatTracker: ActiveChatTracker,
private val tokenManager: TokenManager,
private val chatRepository: chats.domain.repository.ChatRepository,
@ApplicationContext private val context: Context
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var isStarted = false
private val processedMessageIds = mutableSetOf<String>()
fun refresh() {
scope.launch {
try {
val chats = chatRepository.getChats()
val total = chats.sumOf { it.unreadCount }
activeChatTracker.setTotalUnreadCount(total)
} catch (e: Exception) {
// Ignore load error
}
}
}
fun start() {
if (isStarted) return
isStarted = true
// Initial count load
refresh()
signalrClient.events
.filterIsInstance<ChatEvent.NewMessage>()
.onEach { event ->
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message
if (message.senderId == currentUserId) return@onEach
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode()
)
when (event) {
is ChatEvent.NewMessage -> {
val currentUserId = tokenManager.getUserId()
val message = event.message
// Don't show if it's our message or already processed
if (message.senderId == currentUserId) return@onEach
if (processedMessageIds.contains(message.id)) return@onEach
// Mark as processed
processedMessageIds.add(message.id)
if (processedMessageIds.size > 200) {
processedMessageIds.remove(processedMessageIds.first())
}
// Increment immediately for UI feedback
activeChatTracker.incrementUnreadCount()
// Refresh total count from source of truth in background
refresh()
// Don't show if this chat is currently open
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
NotificationHelper.showNotification(
context = context,
title = message.sender?.displayName ?: "Новое сообщение",
body = message.content ?: "Вам прислали вложение",
type = "chat",
chatId = message.chatId,
notificationId = message.id.hashCode(),
totalCount = activeChatTracker.totalUnreadCount.value
)
}
is ChatEvent.MessagesRead -> {
// If anyone read messages, sync our total count
refresh()
}
else -> Unit
}
}
.launchIn(scope)
}

View File

@@ -131,4 +131,11 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
return api.getGifCategories().data.categories
}
override suspend fun createPersonalChat(userId: String): Chat {
val currentUserId = tokenManager.getUserId() ?: ""
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
}
}

View File

@@ -22,5 +22,6 @@ interface ChatRepository {
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
suspend fun createPersonalChat(userId: String): Chat
}

View File

@@ -51,6 +51,7 @@ class ChatDetailViewModel @Inject constructor(
private val serverConfig: ServerConfig,
private val tokenManager: TokenManager,
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
) : ViewModel() {
@@ -275,6 +276,7 @@ class ChatDetailViewModel @Inject constructor(
currentState.copy(messages = updatedMessages)
}
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
signalrNotificationObserver.refresh()
} catch (e: Exception) {
// Ignore
}

View File

@@ -1,78 +1,181 @@
package chats.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
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.sp
import coil.compose.AsyncImage
import core.utils.LinkMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
@Composable
fun LinkPreview(
metadata: LinkMetadata,
onClick: () -> Unit,
url: String,
modifier: Modifier = Modifier,
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant
contentColor: Color = Color.White
) {
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(linkMetadataCache[url]) }
var loading by remember(url) { mutableStateOf(metadata == null) }
val context = LocalContext.current
LaunchedEffect(url) {
if (metadata != null) {
loading = false
return@LaunchedEffect
}
loading = true
try {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val request = okhttp3.Request.Builder()
.url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}")
.header("User-Agent", "KnotMessenger/1.0 (Android)")
.build()
withContext(Dispatchers.IO) {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
val body = response.body?.string()
if (body != null) {
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
if (json.has("status") && json.get("status").asString == "success") {
val data = json.getAsJsonObject("data")
val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString
val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString
val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString
if (title != null || description != null || imageUrl != null) {
metadata = LinkMetadata(
url = url,
title = title,
description = description,
imageUrl = imageUrl
)
metadata?.let { linkMetadataCache[url] = it }
}
Unit
}
Unit
}
Unit
} else {
android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}")
}
}
Unit
}
} catch (e: Exception) {
android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e)
} finally {
loading = false
}
}
if (loading) {
Text(
text = "Загрузка предпросмотра...",
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.5f),
modifier = modifier.padding(vertical = 4.dp),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
return
}
val currentMetadata = metadata ?: LinkMetadata(url = url)
Column(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(contentColor.copy(alpha = 0.05f))
.clickable(onClick = onClick)
.padding(8.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Black.copy(alpha = 0.2f))
.clickable {
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {}
}
.border(
width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp,
color = Color.White.copy(alpha = 0.1f),
shape = RoundedCornerShape(8.dp)
)
) {
if (metadata.imageUrl != null) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
Box(
modifier = Modifier
.fillMaxHeight()
.width(3.dp)
.background(Color(0xFF3096E5))
)
Column(modifier = Modifier.padding(10.dp)) {
val domain = remember(url) {
try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" }
}
Text(
text = domain,
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF3096E5),
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 2.dp)
)
Text(
text = currentMetadata.title ?: url,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
currentMetadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.7f),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
}
}
currentMetadata.imageUrl?.let {
AsyncImage(
model = metadata.imageUrl,
model = it,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(8.dp)),
.heightIn(max = 200.dp)
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(8.dp))
}
metadata.title?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
metadata.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
lineHeight = 16.sp
)
}
Text(
text = metadata.url.removePrefix("https://").removePrefix("http://").split("/")[0],
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
}
}

View File

@@ -255,11 +255,59 @@ fun MessageBubble(
// Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = contentColor
)
Column {
val annotatedString = remember(message.content) {
val text = message.content ?: ""
val links = core.utils.LinkParser.findLinks(text)
androidx.compose.ui.text.buildAnnotatedString {
append(text)
links.forEach { link ->
val startIndex = text.indexOf(link)
if (startIndex >= 0) {
val endIndex = startIndex + link.length
addStyle(
style = androidx.compose.ui.text.SpanStyle(
color = Color(0xFF3096E5),
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
),
start = startIndex,
end = endIndex
)
addStringAnnotation(
tag = "URL",
annotation = link,
start = startIndex,
end = endIndex
)
}
}
}
}
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.foundation.text.ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
try {
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
context.startActivity(intent)
} catch (e: Exception) {}
}
}
)
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
if (links.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
LinkPreview(
url = links[0],
contentColor = contentColor
)
}
}
}
// Time and Status

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -1,5 +1,6 @@
package core.di
import com.google.gson.GsonBuilder
import core.network.AuthInterceptor
import core.network.DynamicBaseUrlInterceptor
import core.network.ServerConfig
@@ -25,7 +26,7 @@ object NetworkModule {
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
): OkHttpClient {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.HEADERS
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
@@ -40,10 +41,13 @@ object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
val gson = com.google.gson.GsonBuilder()
.serializeNulls()
.create()
return Retrofit.Builder()
.baseUrl("https://api.placeholder.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
}

View File

@@ -5,10 +5,15 @@ import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@Singleton
class AuthInterceptor @Inject constructor(
private val tokenManager: TokenManager
private val tokenManager: TokenManager,
private val navigationManager: core.utils.NavigationManager,
private val authRepositoryProvider: javax.inject.Provider<auth.domain.repository.AuthRepository>
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenManager.getToken()
@@ -17,6 +22,17 @@ class AuthInterceptor @Inject constructor(
addHeader("Authorization", "Bearer $it")
}
}.build()
return chain.proceed(request)
val response = chain.proceed(request)
if (response.code == 401) {
// Global logout
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.Main) {
authRepositoryProvider.get().logout()
navigationManager.logout()
}
}
return response
}
}

View File

@@ -11,7 +11,18 @@ class ActiveChatTracker @Inject constructor() {
private val _currentChatId = MutableStateFlow<String?>(null)
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
private val _totalUnreadCount = MutableStateFlow(0)
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount.asStateFlow()
fun setChatId(chatId: String?) {
_currentChatId.value = chatId
}
fun setTotalUnreadCount(count: Int) {
_totalUnreadCount.value = count
}
fun incrementUnreadCount() {
_totalUnreadCount.value += 1
}
}

View File

@@ -23,6 +23,9 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var authApi: AuthApi
@Inject
lateinit var activeChatTracker: ActiveChatTracker
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
@@ -48,11 +51,11 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
val chatId = message.data["chatId"]
val messageId = message.data["id"] ?: message.messageId
NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode())
NotificationHelper.showNotification(this, title, body, type, chatId, messageId?.hashCode(), activeChatTracker.totalUnreadCount.value)
}
private fun showNotification(title: String?, body: String?, type: String?) {
NotificationHelper.showNotification(this, title, body, type)
NotificationHelper.showNotification(this, title, body, type, totalCount = activeChatTracker.totalUnreadCount.value)
}
override fun onDestroy() {

View File

@@ -11,7 +11,7 @@ import androidx.core.app.NotificationCompat
object NotificationHelper {
private const val CHANNEL_ID = "fork_notifications_channel"
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null) {
fun showNotification(context: Context, title: String?, body: String?, type: String?, chatId: String? = null, notificationId: Int? = null, totalCount: Int = 0) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
@@ -48,6 +48,7 @@ object NotificationHelper {
.setAutoCancel(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setNumber(totalCount)
.setContentIntent(pendingIntent)
notificationManager.notify(finalNotificationId, notificationBuilder.build())

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import androidx.compose.foundation.shape.RoundedCornerShape
import core.presentation.theme.SoftSquareShape
@Composable
@@ -45,10 +46,14 @@ fun AppAvatar(
val avatarColor = Color(0xFF4AA6F3) // Premium Telegram Blue (Web)
val cornerRadius = remember(size) {
if (size <= 32.dp) (size.value * 0.25).dp else 12.dp
}
Box(
modifier = modifier
.size(size)
.clip(SoftSquareShape)
.clip(RoundedCornerShape(cornerRadius))
.background(avatarColor),
contentAlignment = Alignment.Center
) {

View File

@@ -130,17 +130,11 @@ private fun RowScope.ProfileNavItem(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.size(26.dp)
.clip(CircleShape)
) {
AppAvatar(
url = avatarUrl,
name = username,
size = 26.dp
)
}
AppAvatar(
url = avatarUrl,
name = username,
size = 26.dp
)
Text(
text = stringResource(R.string.profile_tab),
color = contentColor,

View File

@@ -9,11 +9,12 @@ data class LinkMetadata(
object LinkParser {
private val URL_PATTERN = Regex(
"(?:^|[\\s])((https?://)[\\w-]+(\\.[\\w-]+)+\\.?(:\\d+)?(/[\\w- ./?%&=]*)?)",
"(?:^|[\\s])(https?://[^\\s\\n\\r\"]+)",
RegexOption.IGNORE_CASE
)
fun findLinks(text: String): List<String> {
if (text.isEmpty()) return emptyList()
return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList()
}
}

View File

@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
sealed class NavEvent {
data class OpenChat(val chatId: String) : NavEvent()
object Logout : NavEvent()
}
@Singleton
@@ -16,4 +17,8 @@ class NavigationManager @Inject constructor() {
fun navigateToChat(chatId: String) {
_events.tryEmit(NavEvent.OpenChat(chatId))
}
fun logout() {
_events.tryEmit(NavEvent.Logout)
}
}

View File

@@ -4,9 +4,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -68,6 +66,11 @@ fun AppNavigation(
}
}
}
core.utils.NavEvent.Logout -> {
navController.navigate(Screen.Login.route) {
popUpTo(0) { inclusive = true }
}
}
}
}
}
@@ -83,6 +86,15 @@ fun AppNavigation(
Screen.Profile.route
)
val profileViewModel: ProfileViewModel = hiltViewModel()
val profileState by profileViewModel.state.collectAsState()
androidx.compose.runtime.LaunchedEffect(authState.isAuthenticated) {
if (authState.isAuthenticated) {
profileViewModel.loadProfile()
}
}
Scaffold(
bottomBar = {
if (showBottomBar) {
@@ -95,8 +107,8 @@ fun AppNavigation(
restoreState = true
}
},
userAvatarUrl = null,
username = "Me"
userAvatarUrl = profileState.profile?.avatarUrl,
username = profileState.profile?.displayName ?: profileState.profile?.username ?: ""
)
}
}
@@ -166,9 +178,14 @@ fun AppNavigation(
val state by viewModel.state.collectAsState()
ContactListScreen(
contacts = state.contacts,
requests = state.requests,
isLoading = state.isLoading,
onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) },
onSearchChange = { viewModel.onSearchChange(it) }
onSearchChange = { viewModel.onSearchChange(it) },
onAddContact = { id -> viewModel.addContact(id) },
onStartChat = { id -> viewModel.startChat(id) },
onAcceptRequest = { id -> viewModel.acceptRequest(id) },
onDeclineRequest = { id -> viewModel.declineRequest(id) }
)
}
composable(
@@ -192,17 +209,46 @@ fun AppNavigation(
)
}
composable(Screen.EditProfile.route) {
val viewModel: ProfileViewModel = hiltViewModel()
// Используем ViewModel от предыдущего экрана (Profile), чтобы данные не терялись
val profileEntry = remember(it) {
navController.getBackStackEntry(Screen.Profile.route)
}
val viewModel: ProfileViewModel = hiltViewModel(profileEntry)
val state by viewModel.state.collectAsState()
// Получаем profileId из родительского backStackEntry
val profileId = profileEntry.arguments?.getString("profileId")
androidx.compose.runtime.LaunchedEffect(state.profile) {
// Если профиль загружен (или обновился), мы можем инициализировать/обновить поля
}
val isSaving = state.isSaving
val error = state.error
// Автоматически возвращаемся назад после успешного сохранения
var wasSaving by remember { mutableStateOf(false) }
androidx.compose.runtime.LaunchedEffect(isSaving) {
if (isSaving) wasSaving = true
if (wasSaving && !isSaving && error == null) {
// Перезагружаем профиль перед возвратом
viewModel.loadProfile(profileId)
// Небольшая задержка чтобы данные обновились
kotlinx.coroutines.delay(300)
navController.popBackStack()
}
}
EditProfileScreen(
currentDisplayName = state.profile?.displayName,
currentUsername = state.profile?.username ?: "",
currentBio = state.profile?.bio,
currentBirthday = state.profile?.birthday,
currentAvatarUrl = state.profile?.avatarUrl,
onSave = { name, bio, uri ->
viewModel.updateProfile(name, bio, uri)
navController.popBackStack()
isSaving = isSaving,
error = error,
onSave = { name, bio, birthday, uri ->
viewModel.updateProfile(name, bio, birthday, uri)
},
onBack = { navController.popBackStack() }
)

View File

@@ -9,7 +9,7 @@ interface ProfileApi {
@GET("profiles/{id}")
suspend fun getProfile(@Path("id") id: String): ProfileDto
@GET("profiles/me")
@GET("auth/me")
suspend fun getMyProfile(): ProfileDto
@PUT("profiles/profile")

View File

@@ -1,16 +1,29 @@
package profiles.data.remote.dto
data class ProfileDto(
@com.google.gson.annotations.SerializedName("id", alternate = ["userId"])
val id: String,
@com.google.gson.annotations.SerializedName("username", alternate = ["userName"])
val username: String,
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
@com.google.gson.annotations.SerializedName("avatarUrl", alternate = ["avatar"])
val avatarUrl: String?,
@com.google.gson.annotations.SerializedName("bio", alternate = ["about"])
val bio: String?,
@com.google.gson.annotations.SerializedName("isOnline")
val isOnline: Boolean = false,
val lastSeen: String? = null
@com.google.gson.annotations.SerializedName("lastSeen")
val lastSeen: String? = null,
@com.google.gson.annotations.SerializedName("birthday")
val birthday: String? = null
)
data class UpdateProfileRequest(
@com.google.gson.annotations.SerializedName("displayName")
val displayName: String?,
val bio: String?
@com.google.gson.annotations.SerializedName("bio")
val bio: String?,
@com.google.gson.annotations.SerializedName("birthday")
val birthday: String? = null
)

View File

@@ -15,30 +15,40 @@ import javax.inject.Inject
class ProfileRepositoryImpl @Inject constructor(
private val api: ProfileApi,
private val context: Context
private val context: Context,
private val serverConfig: core.network.ServerConfig
) : ProfileRepository {
private fun ProfileDto.fixAvatarUrl(): ProfileDto {
if (avatarUrl == null || avatarUrl.startsWith("http")) return this
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return copy(avatarUrl = baseUrl + avatarUrl)
}
override suspend fun getProfile(id: String): Result<ProfileDto> = runCatching {
api.getProfile(id)
api.getProfile(id).fixAvatarUrl()
}
override suspend fun getMyProfile(): Result<ProfileDto> = runCatching {
api.getMyProfile()
// Сначала получаем ID пользователя из /auth/me
val me = api.getMyProfile()
// Затем загружаем полный профиль из /profiles/{id}
api.getProfile(me.id).fixAvatarUrl()
}
override suspend fun updateProfile(displayName: String?, bio: String?): Result<ProfileDto> = runCatching {
api.updateProfile(UpdateProfileRequest(displayName, bio))
override suspend fun updateProfile(displayName: String?, bio: String?, birthday: String?): Result<ProfileDto> = runCatching {
api.updateProfile(UpdateProfileRequest(displayName, bio, birthday)).fixAvatarUrl()
}
override suspend fun uploadAvatar(uri: Uri): Result<ProfileDto> = runCatching {
val file = uriToFile(uri)
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("avatar", file.name, requestFile)
api.uploadAvatar(body)
api.uploadAvatar(body).fixAvatarUrl()
}
override suspend fun removeAvatar(): Result<ProfileDto> = runCatching {
api.removeAvatar()
api.removeAvatar().fixAvatarUrl()
}
private fun uriToFile(uri: Uri): File {

View File

@@ -23,7 +23,11 @@ object ProfileModule {
@Provides
@Singleton
fun provideProfileRepository(api: ProfileApi, context: Context): ProfileRepository {
return ProfileRepositoryImpl(api, context)
fun provideProfileRepository(
api: ProfileApi,
context: Context,
serverConfig: core.network.ServerConfig
): ProfileRepository {
return ProfileRepositoryImpl(api, context, serverConfig)
}
}

View File

@@ -6,7 +6,7 @@ import profiles.data.remote.dto.ProfileDto
interface ProfileRepository {
suspend fun getProfile(id: String): Result<ProfileDto>
suspend fun getMyProfile(): Result<ProfileDto>
suspend fun updateProfile(displayName: String?, bio: String?): Result<ProfileDto>
suspend fun updateProfile(displayName: String?, bio: String?, birthday: String? = null): Result<ProfileDto>
suspend fun uploadAvatar(uri: Uri): Result<ProfileDto>
suspend fun removeAvatar(): Result<ProfileDto>
}

View File

@@ -12,8 +12,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -30,6 +32,10 @@ import core.presentation.components.AppAvatar
import core.presentation.theme.SoftSquareShape
import ru.knot.messager.R
import android.app.DatePickerDialog
import java.text.SimpleDateFormat
import java.util.*
import android.app.Activity
import android.content.Intent
import com.yalantis.ucrop.UCrop
@@ -43,14 +49,62 @@ fun EditProfileScreen(
currentDisplayName: String?,
currentUsername: String,
currentBio: String?,
currentBirthday: String?,
currentAvatarUrl: String?,
onSave: (displayName: String, bio: String, avatarUri: Uri?) -> Unit,
isSaving: Boolean = false,
error: String? = null,
onSave: (displayName: String, bio: String, birthday: String?, avatarUri: Uri?) -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
var displayName by remember { mutableStateOf(currentDisplayName ?: "") }
var bio by remember { mutableStateOf(currentBio ?: "") }
var displayName by remember(currentDisplayName) { mutableStateOf(currentDisplayName ?: "") }
var bio by remember(currentBio) { mutableStateOf(currentBio ?: "") }
var birthday by remember(currentBirthday) { mutableStateOf(currentBirthday ?: "") }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
var showDatePicker by remember { mutableStateOf(false) }
// Парсим текущую дату для инициализации DatePicker и отображения
val calendar = Calendar.getInstance()
var formattedBirthday by remember { mutableStateOf("") }
if (birthday.isNotEmpty()) {
try {
// Пробуем распарсить ISO8601 формат (yyyy-MM-dd или yyyy-MM-ddTHH:mm:ss)
val format = when {
birthday.contains("T") -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
birthday.length == 10 -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
else -> SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
}
format.parse(birthday)?.let {
calendar.time = it
// Форматируем для отображения: ДД.ММ.ГГГГ
formattedBirthday = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(it)
}
} catch (e: Exception) {
formattedBirthday = birthday
}
}
// DatePickerDialog
if (showDatePicker) {
DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
val selectedCalendar = Calendar.getInstance()
selectedCalendar.set(year, month, dayOfMonth)
// Используем ISO8601 формат для отправки на сервер
val format = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
birthday = format.format(selectedCalendar.time)
},
calendar.get(Calendar.YEAR),
calendar.get(Calendar.MONTH),
calendar.get(Calendar.DAY_OF_MONTH)
).apply {
datePicker.maxDate = System.currentTimeMillis()
show()
}
showDatePicker = false
}
// Лаунчер для результата кропа
val cropLauncher = rememberLauncherForActivityResult(
@@ -78,17 +132,28 @@ fun EditProfileScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.edit_profile)) },
title = { Text(stringResource(R.string.edit_profile), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
actions = {
IconButton(onClick = { onSave(displayName, bio, selectedImageUri) }) {
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save), tint = MaterialTheme.colorScheme.primary)
TextButton(
onClick = { onSave(displayName, bio, birthday, selectedImageUri) },
enabled = !isSaving
) {
Text(
if (isSaving) stringResource(R.string.saving) else stringResource(R.string.save),
fontWeight = FontWeight.Bold,
color = if (isSaving) Color.Gray else MaterialTheme.colorScheme.primary
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White
)
)
}
) { paddingValues ->
@@ -132,13 +197,14 @@ fun EditProfileScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
.background(Color.Black.copy(alpha = 0.4f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.CameraAlt,
contentDescription = stringResource(R.string.change_photo),
tint = Color.White
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(28.dp)
)
}
}
@@ -194,14 +260,65 @@ fun EditProfileScreen(
maxLines = 5
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = formattedBirthday,
onValueChange = { },
label = { Text("Дата рождения") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
shape = SoftSquareShape,
placeholder = { Text("ДД.ММ.ГГГГ") },
trailingIcon = {
Row {
if (birthday.isNotEmpty()) {
IconButton(onClick = { birthday = "" }) {
Icon(
Icons.Default.Clear,
contentDescription = "Очистить",
tint = Color.Gray
)
}
}
IconButton(onClick = { showDatePicker = true }) {
Icon(
Icons.Default.CalendarToday,
contentDescription = "Выбрать дату",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
)
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { onSave(displayName, bio, selectedImageUri) },
onClick = { onSave(displayName, bio, birthday, selectedImageUri) },
modifier = Modifier.fillMaxWidth(),
shape = SoftSquareShape
shape = SoftSquareShape,
enabled = !isSaving
) {
Text(stringResource(R.string.save))
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.saving))
} else {
Text(stringResource(R.string.save))
}
}
}
}

View File

@@ -41,13 +41,17 @@ fun ProfileScreen(
) {
val state by viewModel.state.collectAsState()
LaunchedEffect(profileId) {
// Загружаем профиль при первом запуске
LaunchedEffect(Unit) {
android.util.Log.d("ProfileScreen", "Loading profile with profileId=$profileId")
viewModel.loadProfile(profileId)
}
val profile = state.profile
val isOwnProfile = profileId == null
android.util.Log.d("ProfileScreen", "Profile loaded: displayName=${profile?.displayName}, bio=${profile?.bio}, birthday=${profile?.birthday}")
val mediaTabs = listOf(
stringResource(R.string.media),
"GIF",
@@ -68,7 +72,7 @@ fun ProfileScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isOwnProfile) stringResource(R.string.profile) else stringResource(R.string.profile)) },
title = { Text(if (isOwnProfile) stringResource(R.string.profile) else stringResource(R.string.profile), fontWeight = FontWeight.Bold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
@@ -80,7 +84,13 @@ fun ProfileScreen(
Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.edit_profile))
}
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
titleContentColor = Color.White,
actionIconContentColor = Color.White,
navigationIconContentColor = Color.White
)
)
}
) { paddingValues ->
@@ -95,6 +105,7 @@ fun ProfileScreen(
displayName = profile?.displayName,
avatarUrl = profile?.avatarUrl,
bio = profile?.bio,
birthday = profile?.birthday,
isOwnProfile = isOwnProfile,
isCallsEnabled = true,
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
@@ -143,6 +154,7 @@ fun ProfileHeader(
displayName: String?,
avatarUrl: String?,
bio: String?,
birthday: String?,
isOwnProfile: Boolean,
isCallsEnabled: Boolean,
onSendMessage: () -> Unit,
@@ -190,11 +202,27 @@ fun ProfileHeader(
}
}
if (bio != null) {
if (bio != null || birthday != null) {
Spacer(modifier = Modifier.height(24.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = stringResource(R.string.bio), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(text = bio, style = MaterialTheme.typography.bodyMedium)
if (birthday != null) {
Text(text = "Дата рождения", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
// Форматируем дату из ISO8601 в dd.MM.yyyy
val formattedBirthday = try {
val format = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault())
val date = format.parse(birthday)
val outputFormat = java.text.SimpleDateFormat("dd.MM.yyyy", java.util.Locale.getDefault())
date?.let { outputFormat.format(it) } ?: birthday
} catch (e: Exception) {
birthday
}
Text(text = formattedBirthday, style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
}
if (bio != null) {
Text(text = stringResource(R.string.bio), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(text = bio, style = MaterialTheme.typography.bodyMedium)
}
}
}
}

View File

@@ -44,23 +44,38 @@ class ProfileViewModel @Inject constructor(
}
}
fun updateProfile(displayName: String?, bio: String?, avatarUri: Uri? = null) {
fun updateProfile(displayName: String?, bio: String?, birthday: String? = null, avatarUri: Uri? = null) {
viewModelScope.launch {
_state.value = _state.value.copy(isSaving = true)
_state.value = _state.value.copy(isSaving = true, error = null)
// 1. Обновляем аватар, если он выбран
avatarUri?.let {
repository.uploadAvatar(it)
}
try {
// 1. Обновляем аватар, если он выбран
avatarUri?.let { uri ->
repository.uploadAvatar(uri).onFailure { error ->
throw Exception(error.message ?: "Failed to upload avatar")
}
}
// 2. Обновляем текстовые данные
repository.updateProfile(displayName, bio)
.onSuccess { updatedProfile ->
_state.value = _state.value.copy(profile = updatedProfile, isSaving = false)
}
.onFailure { error ->
_state.value = _state.value.copy(error = error.message, isSaving = false)
}
// 2. Обновляем текстовые данные
// Пустую строку преобразуем в null для даты рождения
val birthdayToSend = if (birthday.isNullOrBlank()) null else birthday
// Логирование для отладки
android.util.Log.d("ProfileViewModel", "updateProfile: displayName=$displayName, bio=$bio, birthday=$birthday, birthdayToSend=$birthdayToSend")
repository.updateProfile(displayName, bio, birthdayToSend)
.onSuccess { updatedProfile ->
android.util.Log.d("ProfileViewModel", "updateProfile success: birthday=${updatedProfile.birthday}")
_state.value = _state.value.copy(profile = updatedProfile, isSaving = false)
}
.onFailure { error ->
android.util.Log.e("ProfileViewModel", "updateProfile error: ${error.message}")
_state.value = _state.value.copy(error = error.message, isSaving = false)
}
} catch (e: Exception) {
android.util.Log.e("ProfileViewModel", "updateProfile exception: ${e.message}")
_state.value = _state.value.copy(error = e.message, isSaving = false)
}
}
}

View File

@@ -16,3 +16,12 @@ dependencyResolutionManagement {
rootProject.name = "KnotMessenger"
include(":app")
include(":profiles")
include(":auth")
include(":navigation")
include(":chats")
include(":contacts")
include(":calls")
include(":stories")
include(":settings")
include(":core")

View File

@@ -25,8 +25,11 @@ interface StoryApi {
}
data class StoryGroupDto(
val userId: String,
val username: String,
@com.google.gson.annotations.SerializedName("userId")
val userId: String?,
@com.google.gson.annotations.SerializedName("username", alternate = ["userName"])
val username: String?,
@com.google.gson.annotations.SerializedName("avatar", alternate = ["avatarUrl"])
val avatar: String?,
val stories: List<StoryDto>
)

View File

@@ -18,11 +18,12 @@ import core.presentation.theme.SoftSquareShape
@Composable
fun StoryThumbnail(
username: String,
username: String?,
avatarUrl: String?,
hasUnseen: Boolean,
onClick: () -> Unit
) {
val displayUsername = username ?: "User"
val gradientBrush = Brush.sweepGradient(
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Cyan)
)
@@ -45,7 +46,7 @@ fun StoryThumbnail(
) {
AppAvatar(
url = avatarUrl,
name = username,
name = displayUsername,
size = 60.dp,
modifier = Modifier.padding(2.dp)
)
@@ -54,7 +55,7 @@ fun StoryThumbnail(
Spacer(modifier = Modifier.height(4.dp))
Text(
text = username,
text = displayUsername,
fontSize = 11.sp,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurface