Профиль, редактирование без аватара
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="chats">Чаты</string>
|
||||||
<string name="contacts_tab">Контакты</string>
|
<string name="contacts_tab">Контакты</string>
|
||||||
<string name="profile_tab">Профиль</string>
|
<string name="profile_tab">Профиль</string>
|
||||||
|
<string name="saving">Сохранение...</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import auth.domain.model.AuthResult
|
|||||||
import auth.domain.repository.AuthRepository
|
import auth.domain.repository.AuthRepository
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AuthRepositoryImpl @Inject constructor(
|
class AuthRepositoryImpl @Inject constructor(
|
||||||
@@ -14,6 +17,8 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
private val serverConfig: ServerConfig
|
private val serverConfig: ServerConfig
|
||||||
) : AuthRepository {
|
) : AuthRepository {
|
||||||
|
|
||||||
|
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
|
||||||
|
|
||||||
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
||||||
return try {
|
return try {
|
||||||
val response = api.login(AuthRequest(userName, password))
|
val response = api.login(AuthRequest(userName, password))
|
||||||
@@ -21,6 +26,7 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
val userId = response.userId ?: ""
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
tokenManager.saveToken(token, userId)
|
tokenManager.saveToken(token, userId)
|
||||||
|
_isAuthenticated.value = true
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
Result.success(
|
Result.success(
|
||||||
AuthResult(
|
AuthResult(
|
||||||
@@ -43,6 +49,7 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
val userId = response.userId ?: ""
|
val userId = response.userId ?: ""
|
||||||
|
|
||||||
tokenManager.saveToken(token, userId)
|
tokenManager.saveToken(token, userId)
|
||||||
|
_isAuthenticated.value = true
|
||||||
fetchConfig()
|
fetchConfig()
|
||||||
Result.success(
|
Result.success(
|
||||||
AuthResult(
|
AuthResult(
|
||||||
@@ -60,10 +67,15 @@ class AuthRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun logout() {
|
override suspend fun logout() {
|
||||||
tokenManager.deleteToken()
|
tokenManager.deleteToken()
|
||||||
|
_isAuthenticated.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isAuthenticated(): Boolean {
|
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> {
|
override suspend fun fetchConfig(): Result<Unit> {
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ interface AuthRepository {
|
|||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
suspend fun fetchConfig(): Result<Unit>
|
suspend fun fetchConfig(): Result<Unit>
|
||||||
fun isAuthenticated(): Boolean
|
fun isAuthenticated(): Boolean
|
||||||
|
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
|
||||||
suspend fun updatePushToken(token: String)
|
suspend fun updatePushToken(token: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,15 +23,20 @@ class AuthViewModel @Inject constructor(
|
|||||||
private val repository: AuthRepository
|
private val repository: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
init {
|
|
||||||
if (repository.isAuthenticated()) {
|
|
||||||
updatePushToken()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
||||||
val state: StateFlow<AuthState> = _state.asStateFlow()
|
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.isAuthenticatedFlow().collect { authenticated ->
|
||||||
|
_state.update { it.copy(isAuthenticated = authenticated) }
|
||||||
|
if (authenticated) {
|
||||||
|
updatePushToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun checkAuth() {
|
fun checkAuth() {
|
||||||
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
|
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ interface ChatApi {
|
|||||||
@POST("messages/{messageId}/reactions")
|
@POST("messages/{messageId}/reactions")
|
||||||
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
|
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")
|
@POST("chats/{chatId}/typing")
|
||||||
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||||
|
|
||||||
@@ -61,6 +64,10 @@ interface ChatApi {
|
|||||||
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class CreatePersonalChatRequest(
|
||||||
|
val userId: String
|
||||||
|
)
|
||||||
|
|
||||||
data class KlipyResponse(
|
data class KlipyResponse(
|
||||||
val data: KlipyDataWrapper
|
val data: KlipyDataWrapper
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
@@ -19,35 +20,74 @@ class SignalRNotificationObserver @Inject constructor(
|
|||||||
private val signalrClient: ChatHubClient,
|
private val signalrClient: ChatHubClient,
|
||||||
private val activeChatTracker: ActiveChatTracker,
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
|
private val chatRepository: chats.domain.repository.ChatRepository,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var isStarted = false
|
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() {
|
fun start() {
|
||||||
if (isStarted) return
|
if (isStarted) return
|
||||||
isStarted = true
|
isStarted = true
|
||||||
|
|
||||||
|
// Initial count load
|
||||||
|
refresh()
|
||||||
|
|
||||||
signalrClient.events
|
signalrClient.events
|
||||||
.filterIsInstance<ChatEvent.NewMessage>()
|
|
||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
val currentUserId = tokenManager.getUserId()
|
when (event) {
|
||||||
val message = event.message
|
is ChatEvent.NewMessage -> {
|
||||||
|
val currentUserId = tokenManager.getUserId()
|
||||||
// Don't show if it's our message
|
val message = event.message
|
||||||
if (message.senderId == currentUserId) return@onEach
|
|
||||||
|
// Don't show if it's our message or already processed
|
||||||
// Don't show if this chat is currently open
|
if (message.senderId == currentUserId) return@onEach
|
||||||
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
|
if (processedMessageIds.contains(message.id)) return@onEach
|
||||||
|
|
||||||
NotificationHelper.showNotification(
|
// Mark as processed
|
||||||
context = context,
|
processedMessageIds.add(message.id)
|
||||||
title = message.sender?.displayName ?: "Новое сообщение",
|
if (processedMessageIds.size > 200) {
|
||||||
body = message.content ?: "Вам прислали вложение",
|
processedMessageIds.remove(processedMessageIds.first())
|
||||||
type = "chat",
|
}
|
||||||
chatId = message.chatId,
|
|
||||||
notificationId = message.id.hashCode()
|
// 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)
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,4 +131,11 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
||||||
return api.getGifCategories().data.categories
|
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 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 searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
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 serverConfig: ServerConfig,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
|
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
|
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -275,6 +276,7 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
currentState.copy(messages = updatedMessages)
|
currentState.copy(messages = updatedMessages)
|
||||||
}
|
}
|
||||||
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
||||||
|
signalrNotificationObserver.refresh()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,181 @@
|
|||||||
package chats.presentation.components
|
package chats.presentation.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import core.utils.LinkMetadata
|
import core.utils.LinkMetadata
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LinkPreview(
|
fun LinkPreview(
|
||||||
metadata: LinkMetadata,
|
url: String,
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
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(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(contentColor.copy(alpha = 0.05f))
|
.background(Color.Black.copy(alpha = 0.2f))
|
||||||
.clickable(onClick = onClick)
|
.clickable {
|
||||||
.padding(8.dp)
|
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(
|
AsyncImage(
|
||||||
model = metadata.imageUrl,
|
model = it,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(140.dp)
|
.heightIn(max = 200.dp)
|
||||||
.clip(RoundedCornerShape(8.dp)),
|
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
|
||||||
contentScale = ContentScale.Crop
|
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
|
// Text Content
|
||||||
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
|
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
|
||||||
Text(
|
Column {
|
||||||
text = message.content,
|
val annotatedString = remember(message.content) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
val text = message.content ?: ""
|
||||||
color = contentColor
|
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
|
// Time and Status
|
||||||
|
|||||||
@@ -6,21 +6,57 @@ interface ContactApi {
|
|||||||
@GET("contacts")
|
@GET("contacts")
|
||||||
suspend fun getContacts(): List<ContactDto>
|
suspend fun getContacts(): List<ContactDto>
|
||||||
|
|
||||||
@POST("contacts/add")
|
@POST("contacts/request")
|
||||||
suspend fun addContact(@Body userId: String): ContactDto
|
suspend fun addContact(@Body request: AddContactRequest): Any
|
||||||
|
|
||||||
@DELETE("contacts/{userId}")
|
@DELETE("contacts/{userId}")
|
||||||
suspend fun removeContact(@Path("userId") userId: String)
|
suspend fun removeContact(@Path("userId") userId: String)
|
||||||
|
|
||||||
@GET("users/search")
|
@GET("profiles/search")
|
||||||
suspend fun searchUsers(@Query("query") query: String): List<ContactDto>
|
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 id: String,
|
||||||
val username: String,
|
val user: ContactDto,
|
||||||
val displayName: String?,
|
val createdAt: String,
|
||||||
val avatarUrl: String?,
|
val isOutgoing: Boolean
|
||||||
val isOnline: Boolean,
|
)
|
||||||
val lastSeen: String?
|
|
||||||
|
data class ContactDto(
|
||||||
|
@com.google.gson.annotations.SerializedName("id")
|
||||||
|
val id: String,
|
||||||
|
@com.google.gson.annotations.SerializedName("userName")
|
||||||
|
val userName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("username")
|
||||||
|
val username: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("displayName")
|
||||||
|
val displayName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatarUrl")
|
||||||
|
val avatarUrl: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatar")
|
||||||
|
val avatar: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("isOnline")
|
||||||
|
val isOnline: Boolean,
|
||||||
|
@com.google.gson.annotations.SerializedName("lastSeen")
|
||||||
|
val lastSeen: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("friendshipId")
|
||||||
|
val friendshipId: String? = null
|
||||||
|
) {
|
||||||
|
val effectiveUsername: String get() = username ?: userName ?: "unknown"
|
||||||
|
val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AddContactRequest(
|
||||||
|
@com.google.gson.annotations.SerializedName("contactId")
|
||||||
|
val contactId: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package contacts.data.repository
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactApi
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ContactRepositoryImpl @Inject constructor(
|
||||||
|
private val api: ContactApi
|
||||||
|
) : ContactRepository {
|
||||||
|
|
||||||
|
override suspend fun getContacts(): Result<List<ContactDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.getContacts())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addContact(userId: String): Result<ContactDto> {
|
||||||
|
return try {
|
||||||
|
val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId))
|
||||||
|
// Бэкенд возвращает статус или пустой ответ для запроса,
|
||||||
|
// так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или
|
||||||
|
// по-хорошему надо обновить доменную модель, но для начала вернем заглушку.
|
||||||
|
Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeContact(userId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.removeContact(userId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.searchUsers(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.getFriendRequests())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.acceptRequest(friendshipId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.declineRequest(friendshipId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package contacts.di
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactApi
|
||||||
|
import contacts.data.repository.ContactRepositoryImpl
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object ContactModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContactApi(retrofit: Retrofit): ContactApi {
|
||||||
|
return retrofit.create(ContactApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContactRepository(
|
||||||
|
api: ContactApi
|
||||||
|
): ContactRepository {
|
||||||
|
return ContactRepositoryImpl(api)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package contacts.domain.repository
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
|
||||||
|
interface ContactRepository {
|
||||||
|
suspend fun getContacts(): Result<List<ContactDto>>
|
||||||
|
suspend fun addContact(userId: String): Result<ContactDto>
|
||||||
|
suspend fun removeContact(userId: String): Result<Unit>
|
||||||
|
suspend fun searchUsers(query: String): Result<List<ContactDto>>
|
||||||
|
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
|
||||||
|
suspend fun acceptRequest(friendshipId: String): Result<Unit>
|
||||||
|
suspend fun declineRequest(friendshipId: String): Result<Unit>
|
||||||
|
}
|
||||||
@@ -6,10 +6,15 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Search
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import contacts.data.remote.api.ContactDto
|
import contacts.data.remote.api.ContactDto
|
||||||
@@ -20,10 +25,15 @@ import ru.knot.messager.R
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ContactListScreen(
|
fun ContactListScreen(
|
||||||
contacts: List<ContactDto>,
|
contacts: List<contacts.data.remote.api.ContactDto>,
|
||||||
|
requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
|
||||||
isLoading: Boolean = false,
|
isLoading: Boolean = false,
|
||||||
onContactClick: (String) -> Unit,
|
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 searchQuery by remember { mutableStateOf("") }
|
||||||
var selectedTab by remember { mutableIntStateOf(0) }
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
@@ -36,9 +46,10 @@ fun ContactListScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
Column {
|
Column {
|
||||||
TopAppBar(
|
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(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
containerColor = Color.Transparent,
|
||||||
|
titleContentColor = Color.White
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
TabRow(selectedTabIndex = selectedTab) {
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
@@ -78,13 +89,57 @@ fun ContactListScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val filteredContacts = contacts.filter {
|
val filteredContacts = if (searchQuery.isNotEmpty()) {
|
||||||
if (selectedTab == 1) it.isOnline else true
|
contacts
|
||||||
|
} else {
|
||||||
|
contacts.filter {
|
||||||
|
if (selectedTab == 1) it.isOnline else true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
items(filteredContacts) { contact ->
|
// Contact Requests Section
|
||||||
ContactItem(contact = contact, onClick = { onContactClick(contact.id) })
|
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
|
@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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -102,8 +206,8 @@ fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
AppAvatar(
|
AppAvatar(
|
||||||
url = contact.avatarUrl,
|
url = contact.effectiveAvatarUrl,
|
||||||
name = contact.username,
|
name = contact.effectiveUsername,
|
||||||
size = 56.dp
|
size = 56.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,7 +215,7 @@ fun ContactItem(contact: ContactDto, onClick: () -> Unit) {
|
|||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = contact.displayName ?: contact.username,
|
text = contact.displayName ?: contact.effectiveUsername,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
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
|
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.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class ContactListState(
|
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 isLoading: Boolean = false,
|
||||||
val error: String? = null
|
val error: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@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())
|
private val _state = MutableStateFlow(ContactListState())
|
||||||
val state: StateFlow<ContactListState> = _state.asStateFlow()
|
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) {
|
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
|
package core.di
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
import core.network.AuthInterceptor
|
import core.network.AuthInterceptor
|
||||||
import core.network.DynamicBaseUrlInterceptor
|
import core.network.DynamicBaseUrlInterceptor
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
@@ -25,7 +26,7 @@ object NetworkModule {
|
|||||||
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
|
dynamicBaseUrlInterceptor: DynamicBaseUrlInterceptor
|
||||||
): OkHttpClient {
|
): OkHttpClient {
|
||||||
val logging = HttpLoggingInterceptor().apply {
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.HEADERS
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
}
|
}
|
||||||
return OkHttpClient.Builder()
|
return OkHttpClient.Builder()
|
||||||
.connectTimeout(60, TimeUnit.SECONDS)
|
.connectTimeout(60, TimeUnit.SECONDS)
|
||||||
@@ -40,10 +41,13 @@ object NetworkModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
|
||||||
|
val gson = com.google.gson.GsonBuilder()
|
||||||
|
.serializeNulls()
|
||||||
|
.create()
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl("https://api.placeholder.com/")
|
.baseUrl("https://api.placeholder.com/")
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create(gson))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import okhttp3.Interceptor
|
|||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthInterceptor @Inject constructor(
|
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 {
|
) : Interceptor {
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
val token = tokenManager.getToken()
|
val token = tokenManager.getToken()
|
||||||
@@ -17,6 +22,17 @@ class AuthInterceptor @Inject constructor(
|
|||||||
addHeader("Authorization", "Bearer $it")
|
addHeader("Authorization", "Bearer $it")
|
||||||
}
|
}
|
||||||
}.build()
|
}.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)
|
private val _currentChatId = MutableStateFlow<String?>(null)
|
||||||
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
|
val currentChatId: StateFlow<String?> = _currentChatId.asStateFlow()
|
||||||
|
|
||||||
|
private val _totalUnreadCount = MutableStateFlow(0)
|
||||||
|
val totalUnreadCount: StateFlow<Int> = _totalUnreadCount.asStateFlow()
|
||||||
|
|
||||||
fun setChatId(chatId: String?) {
|
fun setChatId(chatId: String?) {
|
||||||
_currentChatId.value = chatId
|
_currentChatId.value = chatId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTotalUnreadCount(count: Int) {
|
||||||
|
_totalUnreadCount.value = count
|
||||||
|
}
|
||||||
|
|
||||||
|
fun incrementUnreadCount() {
|
||||||
|
_totalUnreadCount.value += 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var authApi: AuthApi
|
lateinit var authApi: AuthApi
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var activeChatTracker: ActiveChatTracker
|
||||||
|
|
||||||
private val job = SupervisorJob()
|
private val job = SupervisorJob()
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + job)
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
@@ -48,11 +51,11 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
val chatId = message.data["chatId"]
|
val chatId = message.data["chatId"]
|
||||||
val messageId = message.data["id"] ?: message.messageId
|
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?) {
|
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() {
|
override fun onDestroy() {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import androidx.core.app.NotificationCompat
|
|||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
private const val CHANNEL_ID = "fork_notifications_channel"
|
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 notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|
||||||
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
|
val finalNotificationId = notificationId ?: (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
|
||||||
@@ -48,6 +48,7 @@ object NotificationHelper {
|
|||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
.setDefaults(NotificationCompat.DEFAULT_ALL)
|
||||||
|
.setNumber(totalCount)
|
||||||
.setContentIntent(pendingIntent)
|
.setContentIntent(pendingIntent)
|
||||||
|
|
||||||
notificationManager.notify(finalNotificationId, notificationBuilder.build())
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import core.presentation.theme.SoftSquareShape
|
import core.presentation.theme.SoftSquareShape
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -45,10 +46,14 @@ fun AppAvatar(
|
|||||||
|
|
||||||
val avatarColor = Color(0xFF4AA6F3) // Premium Telegram Blue (Web)
|
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(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(SoftSquareShape)
|
.clip(RoundedCornerShape(cornerRadius))
|
||||||
.background(avatarColor),
|
.background(avatarColor),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -130,17 +130,11 @@ private fun RowScope.ProfileNavItem(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center
|
verticalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
AppAvatar(
|
||||||
modifier = Modifier
|
url = avatarUrl,
|
||||||
.size(26.dp)
|
name = username,
|
||||||
.clip(CircleShape)
|
size = 26.dp
|
||||||
) {
|
)
|
||||||
AppAvatar(
|
|
||||||
url = avatarUrl,
|
|
||||||
name = username,
|
|
||||||
size = 26.dp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.profile_tab),
|
text = stringResource(R.string.profile_tab),
|
||||||
color = contentColor,
|
color = contentColor,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ data class LinkMetadata(
|
|||||||
|
|
||||||
object LinkParser {
|
object LinkParser {
|
||||||
private val URL_PATTERN = Regex(
|
private val URL_PATTERN = Regex(
|
||||||
"(?:^|[\\s])((https?://)[\\w-]+(\\.[\\w-]+)+\\.?(:\\d+)?(/[\\w- ./?%&=]*)?)",
|
"(?:^|[\\s])(https?://[^\\s\\n\\r\"]+)",
|
||||||
RegexOption.IGNORE_CASE
|
RegexOption.IGNORE_CASE
|
||||||
)
|
)
|
||||||
|
|
||||||
fun findLinks(text: String): List<String> {
|
fun findLinks(text: String): List<String> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList()
|
return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
|
|||||||
|
|
||||||
sealed class NavEvent {
|
sealed class NavEvent {
|
||||||
data class OpenChat(val chatId: String) : NavEvent()
|
data class OpenChat(val chatId: String) : NavEvent()
|
||||||
|
object Logout : NavEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -16,4 +17,8 @@ class NavigationManager @Inject constructor() {
|
|||||||
fun navigateToChat(chatId: String) {
|
fun navigateToChat(chatId: String) {
|
||||||
_events.tryEmit(NavEvent.OpenChat(chatId))
|
_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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
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
|
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(
|
Scaffold(
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (showBottomBar) {
|
if (showBottomBar) {
|
||||||
@@ -95,8 +107,8 @@ fun AppNavigation(
|
|||||||
restoreState = true
|
restoreState = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
userAvatarUrl = null,
|
userAvatarUrl = profileState.profile?.avatarUrl,
|
||||||
username = "Me"
|
username = profileState.profile?.displayName ?: profileState.profile?.username ?: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,9 +178,14 @@ fun AppNavigation(
|
|||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
ContactListScreen(
|
ContactListScreen(
|
||||||
contacts = state.contacts,
|
contacts = state.contacts,
|
||||||
|
requests = state.requests,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) },
|
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(
|
composable(
|
||||||
@@ -192,17 +209,46 @@ fun AppNavigation(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.EditProfile.route) {
|
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()
|
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(
|
EditProfileScreen(
|
||||||
currentDisplayName = state.profile?.displayName,
|
currentDisplayName = state.profile?.displayName,
|
||||||
currentUsername = state.profile?.username ?: "",
|
currentUsername = state.profile?.username ?: "",
|
||||||
currentBio = state.profile?.bio,
|
currentBio = state.profile?.bio,
|
||||||
|
currentBirthday = state.profile?.birthday,
|
||||||
currentAvatarUrl = state.profile?.avatarUrl,
|
currentAvatarUrl = state.profile?.avatarUrl,
|
||||||
onSave = { name, bio, uri ->
|
isSaving = isSaving,
|
||||||
viewModel.updateProfile(name, bio, uri)
|
error = error,
|
||||||
navController.popBackStack()
|
onSave = { name, bio, birthday, uri ->
|
||||||
|
viewModel.updateProfile(name, bio, birthday, uri)
|
||||||
},
|
},
|
||||||
onBack = { navController.popBackStack() }
|
onBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface ProfileApi {
|
|||||||
@GET("profiles/{id}")
|
@GET("profiles/{id}")
|
||||||
suspend fun getProfile(@Path("id") id: String): ProfileDto
|
suspend fun getProfile(@Path("id") id: String): ProfileDto
|
||||||
|
|
||||||
@GET("profiles/me")
|
@GET("auth/me")
|
||||||
suspend fun getMyProfile(): ProfileDto
|
suspend fun getMyProfile(): ProfileDto
|
||||||
|
|
||||||
@PUT("profiles/profile")
|
@PUT("profiles/profile")
|
||||||
|
|||||||
@@ -1,16 +1,29 @@
|
|||||||
package profiles.data.remote.dto
|
package profiles.data.remote.dto
|
||||||
|
|
||||||
data class ProfileDto(
|
data class ProfileDto(
|
||||||
|
@com.google.gson.annotations.SerializedName("id", alternate = ["userId"])
|
||||||
val id: String,
|
val id: String,
|
||||||
|
@com.google.gson.annotations.SerializedName("username", alternate = ["userName"])
|
||||||
val username: String,
|
val username: String,
|
||||||
|
@com.google.gson.annotations.SerializedName("displayName")
|
||||||
val displayName: String?,
|
val displayName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatarUrl", alternate = ["avatar"])
|
||||||
val avatarUrl: String?,
|
val avatarUrl: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("bio", alternate = ["about"])
|
||||||
val bio: String?,
|
val bio: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("isOnline")
|
||||||
val isOnline: Boolean = false,
|
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(
|
data class UpdateProfileRequest(
|
||||||
|
@com.google.gson.annotations.SerializedName("displayName")
|
||||||
val displayName: String?,
|
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(
|
class ProfileRepositoryImpl @Inject constructor(
|
||||||
private val api: ProfileApi,
|
private val api: ProfileApi,
|
||||||
private val context: Context
|
private val context: Context,
|
||||||
|
private val serverConfig: core.network.ServerConfig
|
||||||
) : ProfileRepository {
|
) : 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 {
|
override suspend fun getProfile(id: String): Result<ProfileDto> = runCatching {
|
||||||
api.getProfile(id)
|
api.getProfile(id).fixAvatarUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getMyProfile(): Result<ProfileDto> = runCatching {
|
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 {
|
override suspend fun updateProfile(displayName: String?, bio: String?, birthday: String?): Result<ProfileDto> = runCatching {
|
||||||
api.updateProfile(UpdateProfileRequest(displayName, bio))
|
api.updateProfile(UpdateProfileRequest(displayName, bio, birthday)).fixAvatarUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun uploadAvatar(uri: Uri): Result<ProfileDto> = runCatching {
|
override suspend fun uploadAvatar(uri: Uri): Result<ProfileDto> = runCatching {
|
||||||
val file = uriToFile(uri)
|
val file = uriToFile(uri)
|
||||||
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
|
val requestFile = file.asRequestBody("image/jpeg".toMediaTypeOrNull())
|
||||||
val body = MultipartBody.Part.createFormData("avatar", file.name, requestFile)
|
val body = MultipartBody.Part.createFormData("avatar", file.name, requestFile)
|
||||||
api.uploadAvatar(body)
|
api.uploadAvatar(body).fixAvatarUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun removeAvatar(): Result<ProfileDto> = runCatching {
|
override suspend fun removeAvatar(): Result<ProfileDto> = runCatching {
|
||||||
api.removeAvatar()
|
api.removeAvatar().fixAvatarUrl()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun uriToFile(uri: Uri): File {
|
private fun uriToFile(uri: Uri): File {
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ object ProfileModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideProfileRepository(api: ProfileApi, context: Context): ProfileRepository {
|
fun provideProfileRepository(
|
||||||
return ProfileRepositoryImpl(api, context)
|
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 {
|
interface ProfileRepository {
|
||||||
suspend fun getProfile(id: String): Result<ProfileDto>
|
suspend fun getProfile(id: String): Result<ProfileDto>
|
||||||
suspend fun getMyProfile(): 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 uploadAvatar(uri: Uri): Result<ProfileDto>
|
||||||
suspend fun removeAvatar(): 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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
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.CameraAlt
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -30,6 +32,10 @@ import core.presentation.components.AppAvatar
|
|||||||
import core.presentation.theme.SoftSquareShape
|
import core.presentation.theme.SoftSquareShape
|
||||||
import ru.knot.messager.R
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
import android.app.DatePickerDialog
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import com.yalantis.ucrop.UCrop
|
import com.yalantis.ucrop.UCrop
|
||||||
@@ -43,14 +49,62 @@ fun EditProfileScreen(
|
|||||||
currentDisplayName: String?,
|
currentDisplayName: String?,
|
||||||
currentUsername: String,
|
currentUsername: String,
|
||||||
currentBio: String?,
|
currentBio: String?,
|
||||||
|
currentBirthday: String?,
|
||||||
currentAvatarUrl: 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
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var displayName by remember { mutableStateOf(currentDisplayName ?: "") }
|
var displayName by remember(currentDisplayName) { mutableStateOf(currentDisplayName ?: "") }
|
||||||
var bio by remember { mutableStateOf(currentBio ?: "") }
|
var bio by remember(currentBio) { mutableStateOf(currentBio ?: "") }
|
||||||
|
var birthday by remember(currentBirthday) { mutableStateOf(currentBirthday ?: "") }
|
||||||
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
|
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(
|
val cropLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -78,17 +132,28 @@ fun EditProfileScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.edit_profile)) },
|
title = { Text(stringResource(R.string.edit_profile), fontWeight = FontWeight.Bold) },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { onSave(displayName, bio, selectedImageUri) }) {
|
TextButton(
|
||||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.save), tint = MaterialTheme.colorScheme.primary)
|
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 ->
|
) { paddingValues ->
|
||||||
@@ -132,13 +197,14 @@ fun EditProfileScreen(
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = 0.3f)),
|
.background(Color.Black.copy(alpha = 0.4f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default.CameraAlt,
|
Icons.Default.CameraAlt,
|
||||||
contentDescription = stringResource(R.string.change_photo),
|
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
|
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))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = { onSave(displayName, bio, selectedImageUri) },
|
onClick = { onSave(displayName, bio, birthday, selectedImageUri) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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()
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(profileId) {
|
// Загружаем профиль при первом запуске
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
android.util.Log.d("ProfileScreen", "Loading profile with profileId=$profileId")
|
||||||
viewModel.loadProfile(profileId)
|
viewModel.loadProfile(profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
val profile = state.profile
|
val profile = state.profile
|
||||||
val isOwnProfile = profileId == null
|
val isOwnProfile = profileId == null
|
||||||
|
|
||||||
|
android.util.Log.d("ProfileScreen", "Profile loaded: displayName=${profile?.displayName}, bio=${profile?.bio}, birthday=${profile?.birthday}")
|
||||||
|
|
||||||
val mediaTabs = listOf(
|
val mediaTabs = listOf(
|
||||||
stringResource(R.string.media),
|
stringResource(R.string.media),
|
||||||
"GIF",
|
"GIF",
|
||||||
@@ -68,7 +72,7 @@ fun ProfileScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
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 = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBack) {
|
IconButton(onClick = onBack) {
|
||||||
Icon(Icons.Default.ArrowBack, contentDescription = stringResource(R.string.back))
|
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))
|
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 ->
|
) { paddingValues ->
|
||||||
@@ -95,6 +105,7 @@ fun ProfileScreen(
|
|||||||
displayName = profile?.displayName,
|
displayName = profile?.displayName,
|
||||||
avatarUrl = profile?.avatarUrl,
|
avatarUrl = profile?.avatarUrl,
|
||||||
bio = profile?.bio,
|
bio = profile?.bio,
|
||||||
|
birthday = profile?.birthday,
|
||||||
isOwnProfile = isOwnProfile,
|
isOwnProfile = isOwnProfile,
|
||||||
isCallsEnabled = true,
|
isCallsEnabled = true,
|
||||||
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
|
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
|
||||||
@@ -143,6 +154,7 @@ fun ProfileHeader(
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
avatarUrl: String?,
|
avatarUrl: String?,
|
||||||
bio: String?,
|
bio: String?,
|
||||||
|
birthday: String?,
|
||||||
isOwnProfile: Boolean,
|
isOwnProfile: Boolean,
|
||||||
isCallsEnabled: Boolean,
|
isCallsEnabled: Boolean,
|
||||||
onSendMessage: () -> Unit,
|
onSendMessage: () -> Unit,
|
||||||
@@ -190,11 +202,27 @@ fun ProfileHeader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bio != null) {
|
if (bio != null || birthday != null) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = stringResource(R.string.bio), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
|
if (birthday != null) {
|
||||||
Text(text = bio, style = MaterialTheme.typography.bodyMedium)
|
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 {
|
viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isSaving = true)
|
_state.value = _state.value.copy(isSaving = true, error = null)
|
||||||
|
|
||||||
// 1. Обновляем аватар, если он выбран
|
try {
|
||||||
avatarUri?.let {
|
// 1. Обновляем аватар, если он выбран
|
||||||
repository.uploadAvatar(it)
|
avatarUri?.let { uri ->
|
||||||
}
|
repository.uploadAvatar(uri).onFailure { error ->
|
||||||
|
throw Exception(error.message ?: "Failed to upload avatar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Обновляем текстовые данные
|
// 2. Обновляем текстовые данные
|
||||||
repository.updateProfile(displayName, bio)
|
// Пустую строку преобразуем в null для даты рождения
|
||||||
.onSuccess { updatedProfile ->
|
val birthdayToSend = if (birthday.isNullOrBlank()) null else birthday
|
||||||
_state.value = _state.value.copy(profile = updatedProfile, isSaving = false)
|
|
||||||
}
|
// Логирование для отладки
|
||||||
.onFailure { error ->
|
android.util.Log.d("ProfileViewModel", "updateProfile: displayName=$displayName, bio=$bio, birthday=$birthday, birthdayToSend=$birthdayToSend")
|
||||||
_state.value = _state.value.copy(error = error.message, isSaving = false)
|
|
||||||
}
|
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"
|
rootProject.name = "KnotMessenger"
|
||||||
include(":app")
|
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(
|
data class StoryGroupDto(
|
||||||
val userId: String,
|
@com.google.gson.annotations.SerializedName("userId")
|
||||||
val username: String,
|
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 avatar: String?,
|
||||||
val stories: List<StoryDto>
|
val stories: List<StoryDto>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import core.presentation.theme.SoftSquareShape
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StoryThumbnail(
|
fun StoryThumbnail(
|
||||||
username: String,
|
username: String?,
|
||||||
avatarUrl: String?,
|
avatarUrl: String?,
|
||||||
hasUnseen: Boolean,
|
hasUnseen: Boolean,
|
||||||
onClick: () -> Unit
|
onClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
val displayUsername = username ?: "User"
|
||||||
val gradientBrush = Brush.sweepGradient(
|
val gradientBrush = Brush.sweepGradient(
|
||||||
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Cyan)
|
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Cyan)
|
||||||
)
|
)
|
||||||
@@ -45,7 +46,7 @@ fun StoryThumbnail(
|
|||||||
) {
|
) {
|
||||||
AppAvatar(
|
AppAvatar(
|
||||||
url = avatarUrl,
|
url = avatarUrl,
|
||||||
name = username,
|
name = displayUsername,
|
||||||
size = 60.dp,
|
size = 60.dp,
|
||||||
modifier = Modifier.padding(2.dp)
|
modifier = Modifier.padding(2.dp)
|
||||||
)
|
)
|
||||||
@@ -54,7 +55,7 @@ fun StoryThumbnail(
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = username,
|
text = displayUsername,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
|||||||
Reference in New Issue
Block a user