Профиль, редактирование без аватара
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -74,4 +74,5 @@
|
||||
<string name="chats">Чаты</string>
|
||||
<string name="contacts_tab">Контакты</string>
|
||||
<string name="profile_tab">Профиль</string>
|
||||
<string name="saving">Сохранение...</string>
|
||||
</resources>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
client-mobile/contacts/di/ContactModule.kt
Normal file
30
client-mobile/contacts/di/ContactModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user