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