diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin
index 9ea1f9b..a92ab32 100644
Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ
diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock
index a96186d..0e63d7a 100644
Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin
index 4f988c9..ceb39ae 100644
Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock
index 7cff6db..6cfe80b 100644
Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ
diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin
index 4fb23a5..08b687c 100644
Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ
diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock
index f419ebb..6f9e4db 100644
Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ
diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin
index 5ac3a64..aa2667a 100644
Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ
diff --git a/client-mobile/app/src/main/res/values/strings.xml b/client-mobile/app/src/main/res/values/strings.xml
index c6d8b29..3962884 100644
--- a/client-mobile/app/src/main/res/values/strings.xml
+++ b/client-mobile/app/src/main/res/values/strings.xml
@@ -74,4 +74,5 @@
Чаты
Контакты
Профиль
+ Сохранение...
diff --git a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt
index 3ab91d2..5eaf0fb 100644
--- a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt
+++ b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt
@@ -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 {
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 {
+ return _isAuthenticated.asStateFlow()
}
override suspend fun fetchConfig(): Result {
diff --git a/client-mobile/auth/domain/repository/AuthRepository.kt b/client-mobile/auth/domain/repository/AuthRepository.kt
index 381793f..9237d30 100644
--- a/client-mobile/auth/domain/repository/AuthRepository.kt
+++ b/client-mobile/auth/domain/repository/AuthRepository.kt
@@ -8,5 +8,6 @@ interface AuthRepository {
suspend fun logout()
suspend fun fetchConfig(): Result
fun isAuthenticated(): Boolean
+ fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow
suspend fun updatePushToken(token: String)
}
diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt
index 4f1d45e..990e87b 100644
--- a/client-mobile/auth/presentation/AuthViewModel.kt
+++ b/client-mobile/auth/presentation/AuthViewModel.kt
@@ -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 = _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()) }
}
diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt
index 3921e71..b42c72d 100644
--- a/client-mobile/chats/data/remote/api/ChatApi.kt
+++ b/client-mobile/chats/data/remote/api/ChatApi.kt
@@ -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
)
diff --git a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt
index cdc9197..4cc2db8 100644
--- a/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt
+++ b/client-mobile/chats/data/remote/signalr/SignalRNotificationObserver.kt
@@ -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()
+
+ 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()
.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)
}
diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
index 276d75f..f087bc9 100644
--- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
+++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt
@@ -131,4 +131,11 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun getGifCategories(): List {
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)
+ }
}
diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt
index 1411ea5..f405efe 100644
--- a/client-mobile/chats/domain/repository/ChatRepository.kt
+++ b/client-mobile/chats/domain/repository/ChatRepository.kt
@@ -22,5 +22,6 @@ interface ChatRepository {
suspend fun getTrendingGifs(page: Int = 0): List
suspend fun searchGifs(query: String, page: Int = 0): List
suspend fun getGifCategories(): List
+ suspend fun createPersonalChat(userId: String): Chat
}
diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt
index 1fc304d..fe58ee7 100644
--- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt
+++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt
@@ -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
}
diff --git a/client-mobile/chats/presentation/components/LinkPreview.kt b/client-mobile/chats/presentation/components/LinkPreview.kt
index 4b367d5..22c444e 100644
--- a/client-mobile/chats/presentation/components/LinkPreview.kt
+++ b/client-mobile/chats/presentation/components/LinkPreview.kt
@@ -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()
@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(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)
- )
}
}
diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt
index 1966cc5..88516a8 100644
--- a/client-mobile/chats/presentation/components/MessageBubble.kt
+++ b/client-mobile/chats/presentation/components/MessageBubble.kt
@@ -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
diff --git a/client-mobile/contacts/data/remote/api/ContactApi.kt b/client-mobile/contacts/data/remote/api/ContactApi.kt
index 21905d4..7141c0a 100644
--- a/client-mobile/contacts/data/remote/api/ContactApi.kt
+++ b/client-mobile/contacts/data/remote/api/ContactApi.kt
@@ -6,21 +6,57 @@ interface ContactApi {
@GET("contacts")
suspend fun getContacts(): List
- @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
+ @GET("profiles/search")
+ suspend fun searchUsers(@Query("q") query: String): List
+
+ @GET("contacts/requests")
+ suspend fun getFriendRequests(): List
+
+ @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
)
diff --git a/client-mobile/contacts/data/repository/ContactRepositoryImpl.kt b/client-mobile/contacts/data/repository/ContactRepositoryImpl.kt
new file mode 100644
index 0000000..550a37f
--- /dev/null
+++ b/client-mobile/contacts/data/repository/ContactRepositoryImpl.kt
@@ -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> {
+ return try {
+ Result.success(api.getContacts())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun addContact(userId: String): Result {
+ 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 {
+ return try {
+ api.removeContact(userId)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun searchUsers(query: String): Result> {
+ return try {
+ Result.success(api.searchUsers(query))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun getFriendRequests(): Result> {
+ return try {
+ Result.success(api.getFriendRequests())
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun acceptRequest(friendshipId: String): Result {
+ return try {
+ api.acceptRequest(friendshipId)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ override suspend fun declineRequest(friendshipId: String): Result {
+ return try {
+ api.declineRequest(friendshipId)
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/client-mobile/contacts/di/ContactModule.kt b/client-mobile/contacts/di/ContactModule.kt
new file mode 100644
index 0000000..f33a7ae
--- /dev/null
+++ b/client-mobile/contacts/di/ContactModule.kt
@@ -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)
+ }
+}
diff --git a/client-mobile/contacts/domain/repository/ContactRepository.kt b/client-mobile/contacts/domain/repository/ContactRepository.kt
new file mode 100644
index 0000000..8515b67
--- /dev/null
+++ b/client-mobile/contacts/domain/repository/ContactRepository.kt
@@ -0,0 +1,13 @@
+package contacts.domain.repository
+
+import contacts.data.remote.api.ContactDto
+
+interface ContactRepository {
+ suspend fun getContacts(): Result>
+ suspend fun addContact(userId: String): Result
+ suspend fun removeContact(userId: String): Result
+ suspend fun searchUsers(query: String): Result>
+ suspend fun getFriendRequests(): Result>
+ suspend fun acceptRequest(friendshipId: String): Result
+ suspend fun declineRequest(friendshipId: String): Result
+}
diff --git a/client-mobile/contacts/presentation/ContactListScreen.kt b/client-mobile/contacts/presentation/ContactListScreen.kt
index dad5996..8cec1c7 100644
--- a/client-mobile/contacts/presentation/ContactListScreen.kt
+++ b/client-mobile/contacts/presentation/ContactListScreen.kt
@@ -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,
+ contacts: List,
+ requests: List = 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
+ )
+ }
+ }
}
}
diff --git a/client-mobile/contacts/presentation/ContactListViewModel.kt b/client-mobile/contacts/presentation/ContactListViewModel.kt
index a357e15..261745a 100644
--- a/client-mobile/contacts/presentation/ContactListViewModel.kt
+++ b/client-mobile/contacts/presentation/ContactListViewModel.kt
@@ -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 = emptyList(),
+ val contacts: List = emptyList(),
+ val requests: List = 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 = _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) }
+ }
+ }
}
}
diff --git a/client-mobile/core/di/NetworkModule.kt b/client-mobile/core/di/NetworkModule.kt
index 780fdf5..46456ed 100644
--- a/client-mobile/core/di/NetworkModule.kt
+++ b/client-mobile/core/di/NetworkModule.kt
@@ -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()
}
}
diff --git a/client-mobile/core/network/AuthInterceptor.kt b/client-mobile/core/network/AuthInterceptor.kt
index 9a0a569..16a9f3c 100644
--- a/client-mobile/core/network/AuthInterceptor.kt
+++ b/client-mobile/core/network/AuthInterceptor.kt
@@ -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
) : 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
}
}
diff --git a/client-mobile/core/notifications/data/ActiveChatTracker.kt b/client-mobile/core/notifications/data/ActiveChatTracker.kt
index 4ae5ef9..afce742 100644
--- a/client-mobile/core/notifications/data/ActiveChatTracker.kt
+++ b/client-mobile/core/notifications/data/ActiveChatTracker.kt
@@ -11,7 +11,18 @@ class ActiveChatTracker @Inject constructor() {
private val _currentChatId = MutableStateFlow(null)
val currentChatId: StateFlow = _currentChatId.asStateFlow()
+ private val _totalUnreadCount = MutableStateFlow(0)
+ val totalUnreadCount: StateFlow = _totalUnreadCount.asStateFlow()
+
fun setChatId(chatId: String?) {
_currentChatId.value = chatId
}
+
+ fun setTotalUnreadCount(count: Int) {
+ _totalUnreadCount.value = count
+ }
+
+ fun incrementUnreadCount() {
+ _totalUnreadCount.value += 1
+ }
}
diff --git a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt
index 142cb73..b6a00ca 100644
--- a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt
+++ b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt
@@ -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() {
diff --git a/client-mobile/core/notifications/data/NotificationHelper.kt b/client-mobile/core/notifications/data/NotificationHelper.kt
index 3b58b26..fa57e83 100644
--- a/client-mobile/core/notifications/data/NotificationHelper.kt
+++ b/client-mobile/core/notifications/data/NotificationHelper.kt
@@ -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())
diff --git a/client-mobile/core/presentation/components/AppAvatar.kt b/client-mobile/core/presentation/components/AppAvatar.kt
index cf8def9..8a22ffb 100644
--- a/client-mobile/core/presentation/components/AppAvatar.kt
+++ b/client-mobile/core/presentation/components/AppAvatar.kt
@@ -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
) {
diff --git a/client-mobile/core/presentation/components/GlassNavigationBar.kt b/client-mobile/core/presentation/components/GlassNavigationBar.kt
index a319d5f..adf74bc 100644
--- a/client-mobile/core/presentation/components/GlassNavigationBar.kt
+++ b/client-mobile/core/presentation/components/GlassNavigationBar.kt
@@ -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,
diff --git a/client-mobile/core/utils/LinkParser.kt b/client-mobile/core/utils/LinkParser.kt
index 2fc0b4f..49b306e 100644
--- a/client-mobile/core/utils/LinkParser.kt
+++ b/client-mobile/core/utils/LinkParser.kt
@@ -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 {
+ if (text.isEmpty()) return emptyList()
return URL_PATTERN.findAll(text).map { it.groupValues[1].trim() }.toList()
}
}
diff --git a/client-mobile/core/utils/NavigationManager.kt b/client-mobile/core/utils/NavigationManager.kt
index c2ce6d7..87c78c9 100644
--- a/client-mobile/core/utils/NavigationManager.kt
+++ b/client-mobile/core/utils/NavigationManager.kt
@@ -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)
+ }
}
diff --git a/client-mobile/navigation/AppNavigation.kt b/client-mobile/navigation/AppNavigation.kt
index 1600736..73c72bc 100644
--- a/client-mobile/navigation/AppNavigation.kt
+++ b/client-mobile/navigation/AppNavigation.kt
@@ -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() }
)
diff --git a/client-mobile/profiles/data/remote/api/ProfileApi.kt b/client-mobile/profiles/data/remote/api/ProfileApi.kt
index 84c36cb..97c3566 100644
--- a/client-mobile/profiles/data/remote/api/ProfileApi.kt
+++ b/client-mobile/profiles/data/remote/api/ProfileApi.kt
@@ -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")
diff --git a/client-mobile/profiles/data/remote/dto/ProfileDto.kt b/client-mobile/profiles/data/remote/dto/ProfileDto.kt
index 984f72f..67caa0e 100644
--- a/client-mobile/profiles/data/remote/dto/ProfileDto.kt
+++ b/client-mobile/profiles/data/remote/dto/ProfileDto.kt
@@ -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
)
diff --git a/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt b/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt
index 816f5e4..d7c4a72 100644
--- a/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt
+++ b/client-mobile/profiles/data/repository/ProfileRepositoryImpl.kt
@@ -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 = runCatching {
- api.getProfile(id)
+ api.getProfile(id).fixAvatarUrl()
}
override suspend fun getMyProfile(): Result = 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 = runCatching {
- api.updateProfile(UpdateProfileRequest(displayName, bio))
+ override suspend fun updateProfile(displayName: String?, bio: String?, birthday: String?): Result = runCatching {
+ api.updateProfile(UpdateProfileRequest(displayName, bio, birthday)).fixAvatarUrl()
}
override suspend fun uploadAvatar(uri: Uri): Result = 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 = runCatching {
- api.removeAvatar()
+ api.removeAvatar().fixAvatarUrl()
}
private fun uriToFile(uri: Uri): File {
diff --git a/client-mobile/profiles/di/ProfileModule.kt b/client-mobile/profiles/di/ProfileModule.kt
index 9b4f8e8..efe7026 100644
--- a/client-mobile/profiles/di/ProfileModule.kt
+++ b/client-mobile/profiles/di/ProfileModule.kt
@@ -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)
}
}
diff --git a/client-mobile/profiles/domain/repository/ProfileRepository.kt b/client-mobile/profiles/domain/repository/ProfileRepository.kt
index 8e22895..7d38492 100644
--- a/client-mobile/profiles/domain/repository/ProfileRepository.kt
+++ b/client-mobile/profiles/domain/repository/ProfileRepository.kt
@@ -6,7 +6,7 @@ import profiles.data.remote.dto.ProfileDto
interface ProfileRepository {
suspend fun getProfile(id: String): Result
suspend fun getMyProfile(): Result
- suspend fun updateProfile(displayName: String?, bio: String?): Result
+ suspend fun updateProfile(displayName: String?, bio: String?, birthday: String? = null): Result
suspend fun uploadAvatar(uri: Uri): Result
suspend fun removeAvatar(): Result
}
diff --git a/client-mobile/profiles/presentation/EditProfileScreen.kt b/client-mobile/profiles/presentation/EditProfileScreen.kt
index 1af713e..65cf9fb 100644
--- a/client-mobile/profiles/presentation/EditProfileScreen.kt
+++ b/client-mobile/profiles/presentation/EditProfileScreen.kt
@@ -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(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))
+ }
}
}
}
diff --git a/client-mobile/profiles/presentation/ProfileScreen.kt b/client-mobile/profiles/presentation/ProfileScreen.kt
index 2b9dce8..4413f3b 100644
--- a/client-mobile/profiles/presentation/ProfileScreen.kt
+++ b/client-mobile/profiles/presentation/ProfileScreen.kt
@@ -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)
+ }
}
}
}
diff --git a/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt b/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt
index 716bc6f..d6f85cb 100644
--- a/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt
+++ b/client-mobile/profiles/presentation/viewmodel/ProfileViewModel.kt
@@ -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)
+ }
}
}
diff --git a/client-mobile/settings.gradle.kts b/client-mobile/settings.gradle.kts
index f629523..fce93e6 100644
--- a/client-mobile/settings.gradle.kts
+++ b/client-mobile/settings.gradle.kts
@@ -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")
diff --git a/client-mobile/stories/data/remote/api/StoryApi.kt b/client-mobile/stories/data/remote/api/StoryApi.kt
index 3276457..6d246b4 100644
--- a/client-mobile/stories/data/remote/api/StoryApi.kt
+++ b/client-mobile/stories/data/remote/api/StoryApi.kt
@@ -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
)
diff --git a/client-mobile/stories/presentation/components/StoryThumbnail.kt b/client-mobile/stories/presentation/components/StoryThumbnail.kt
index f2e7d47..c9d83b5 100644
--- a/client-mobile/stories/presentation/components/StoryThumbnail.kt
+++ b/client-mobile/stories/presentation/components/StoryThumbnail.kt
@@ -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