diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 65ab3f5..dccdf34 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 2c694d3..d48925e 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 6c4e318..f8ad01b 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 ef26a51..21a4947 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 1ff57d5..3d551d8 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 0fbf02f..4f5865d 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 e5582b0..ffa79b7 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt index 5f2d6c8..5594560 100644 --- a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt +++ b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt @@ -18,13 +18,14 @@ class AuthRepositoryImpl @Inject constructor( return try { val response = api.login(AuthRequest(userName, password)) val token = response.accessToken ?: return Result.failure(Exception("Token is null")) + val userId = response.userId ?: "" - tokenManager.saveToken(token) + tokenManager.saveToken(token, userId) fetchConfig() Result.success( AuthResult( token = token, - userId = response.userId ?: "", + userId = userId, userName = response.username ?: userName, displayName = response.displayName ?: response.username ?: userName, avatarUrl = null @@ -39,13 +40,14 @@ class AuthRepositoryImpl @Inject constructor( return try { val response = api.register(AuthRequest(userName, password)) val token = response.accessToken ?: return Result.failure(Exception("Token is null")) + val userId = response.userId ?: "" - tokenManager.saveToken(token) + tokenManager.saveToken(token, userId) fetchConfig() Result.success( AuthResult( token = token, - userId = response.userId ?: "", + userId = userId, userName = response.username ?: userName, displayName = response.displayName ?: response.username ?: userName, avatarUrl = null @@ -60,6 +62,10 @@ class AuthRepositoryImpl @Inject constructor( tokenManager.deleteToken() } + override fun isAuthenticated(): Boolean { + return tokenManager.getToken() != null + } + override suspend fun fetchConfig(): Result { return try { val config = api.getConfig() diff --git a/client-mobile/auth/domain/repository/AuthRepository.kt b/client-mobile/auth/domain/repository/AuthRepository.kt index fbf8666..7b5b9b8 100644 --- a/client-mobile/auth/domain/repository/AuthRepository.kt +++ b/client-mobile/auth/domain/repository/AuthRepository.kt @@ -7,4 +7,5 @@ interface AuthRepository { suspend fun register(userName: String, password: String): Result suspend fun logout() suspend fun fetchConfig(): Result + fun isAuthenticated(): Boolean } diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt index 10feb5f..87949ee 100644 --- a/client-mobile/auth/presentation/AuthViewModel.kt +++ b/client-mobile/auth/presentation/AuthViewModel.kt @@ -22,9 +22,13 @@ class AuthViewModel @Inject constructor( private val repository: AuthRepository ) : ViewModel() { - private val _state = MutableStateFlow(AuthState()) + private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated())) val state: StateFlow = _state.asStateFlow() + fun checkAuth() { + _state.update { it.copy(isAuthenticated = repository.isAuthenticated()) } + } + fun login(userName: String, password: String) { viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } diff --git a/client-mobile/chats/data/remote/api/ChatApi.kt b/client-mobile/chats/data/remote/api/ChatApi.kt index af98efd..787a2ee 100644 --- a/client-mobile/chats/data/remote/api/ChatApi.kt +++ b/client-mobile/chats/data/remote/api/ChatApi.kt @@ -4,6 +4,21 @@ import chats.data.remote.dto.ChatDto import chats.data.remote.dto.MessageDto import retrofit2.http.* +data class SendMessageRequest( + val content: String?, + val type: String = "text", + val attachments: List? = null, + val replyToId: String? = null, + val quote: String? = null +) + +data class AttachmentRequest( + val type: String, + val url: String, + val fileName: String, + val fileSize: Long +) + interface ChatApi { @GET("chats") suspend fun getChats(): List @@ -16,7 +31,7 @@ interface ChatApi { ): List @POST("messages/chat/{chatId}") - suspend fun sendMessage(@Path("chatId") chatId: String, @Body content: String): MessageDto + suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto @Multipart @POST("messages/upload") diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt index c9fc0f7..5999731 100644 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt @@ -31,6 +31,13 @@ data class ChatDto( @SerializedName("id") val id: String, @SerializedName("type") val type: String, @SerializedName("name") val name: String?, + @SerializedName("avatar") val avatar: String?, @SerializedName("unreadCount") val unreadCount: Int, - @SerializedName("messages") val messages: List + @SerializedName("messages") val messages: List = emptyList(), + @SerializedName("members") val members: List = emptyList() +) + +data class ChatMemberDto( + @SerializedName("userId") val userId: String, + @SerializedName("user") val user: UserBasicDto? ) diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index c2e6574..1df55e0 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -1,20 +1,27 @@ package chats.data.repository import chats.data.remote.api.ChatApi +import chats.data.remote.api.SendMessageRequest import chats.data.remote.dto.ChatDto import chats.data.remote.dto.MessageDto import chats.domain.model.Chat import chats.domain.model.Message import chats.domain.model.MediaType import chats.domain.repository.ChatRepository +import core.security.TokenManager +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody import javax.inject.Inject class ChatRepositoryImpl @Inject constructor( - private val api: ChatApi + private val api: ChatApi, + private val tokenManager: TokenManager ) : ChatRepository { override suspend fun getChats(): List { - return api.getChats().map { it.toDomain() } + val currentUserId = tokenManager.getUserId() ?: "" + return api.getChats().map { it.toDomain(currentUserId) } } override suspend fun getMessages(chatId: String): List { @@ -22,7 +29,8 @@ class ChatRepositoryImpl @Inject constructor( } override suspend fun sendMessage(chatId: String, content: String): Message { - return api.sendMessage(chatId, content).toDomain() + val request = SendMessageRequest(content = content, type = "text") + return api.sendMessage(chatId, request).toDomain() } override suspend fun addReaction(messageId: String, emoji: String) { @@ -36,17 +44,37 @@ class ChatRepositoryImpl @Inject constructor( override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) { api.markMessagesAsRead(chatId, lastMessageId) } + + override suspend fun uploadMedia(file: java.io.File): String { + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + return api.uploadFile(body).url + } } + + + // Mappers -fun ChatDto.toDomain(): Chat = Chat( - id = id, - type = type, - name = name ?: "Unknown Chat", - avatar = null, // Update if avatar is added to ChatDto - unreadCount = unreadCount, - lastMessage = messages.firstOrNull()?.toDomain() -) +fun ChatDto.toDomain(currentUserId: String): Chat { + // Если это личный чат и имя пустое, ищем имя собеседника в списке участников + val chatName = name ?: if (type == "personal") { + members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat" + } else "Group Chat" + + val chatAvatar = avatar ?: if (type == "personal") { + members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl + } else null + + return Chat( + id = id, + type = type, + name = chatName, + avatar = chatAvatar, + unreadCount = unreadCount, + lastMessage = messages.firstOrNull()?.toDomain() + ) +} fun MessageDto.toDomain(): Message = Message( id = id, diff --git a/client-mobile/chats/di/ChatModule.kt b/client-mobile/chats/di/ChatModule.kt index d3a222a..97c0f0c 100644 --- a/client-mobile/chats/di/ChatModule.kt +++ b/client-mobile/chats/di/ChatModule.kt @@ -4,6 +4,7 @@ import chats.data.remote.api.ChatApi import chats.data.remote.signalr.ChatHubClient import chats.data.repository.ChatRepositoryImpl import chats.domain.repository.ChatRepository +import core.security.TokenManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -23,8 +24,8 @@ object ChatModule { @Provides @Singleton - fun provideChatRepository(api: ChatApi): ChatRepository { - return ChatRepositoryImpl(api) + fun provideChatRepository(api: ChatApi, tokenManager: TokenManager): ChatRepository { + return ChatRepositoryImpl(api, tokenManager) } @Provides diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index 13f9d5f..259060b 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -10,4 +10,6 @@ interface ChatRepository { suspend fun addReaction(messageId: String, emoji: String) suspend fun sendTypingStatus(chatId: String) suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) + suspend fun uploadMedia(file: java.io.File): String } + diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index e791263..cfc5ca0 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -1,6 +1,5 @@ package chats.presentation.chat_detail -import android.Manifest import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -23,13 +22,13 @@ import chats.presentation.components.EmojiPicker import chats.presentation.components.MessageBubble import core.utils.VoiceRecorder import core.utils.copyUriToFile -import kotlinx.coroutines.launch import java.io.File import ru.knot.messager.R @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatDetailScreen( + chatId: String, chatName: String, viewModel: ChatDetailViewModel, onBack: () -> Unit @@ -52,6 +51,11 @@ fun ChatDetailScreen( } } + // Загрузка данных чата при входе + LaunchedEffect(chatId) { + viewModel.setChatId(chatId) + } + // Автопрокрутка к последнему сообщению LaunchedEffect(state.messages.size) { if (state.messages.isNotEmpty()) { @@ -112,7 +116,7 @@ fun ChatDetailScreen( items(state.messages) { message -> MessageBubble( message = message, - isCurrentUser = message.senderId == "CURRENT_USER_ID" // TODO: Get from Auth + isCurrentUser = message.senderId == viewModel.getCurrentUserId() ) } } @@ -163,7 +167,6 @@ fun ChatDetailScreen( } else { voiceRecorder.stopRecording() isRecording = false - // TODO: Send voice file } } ) { diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 6083487..142504a 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -8,6 +8,7 @@ import chats.domain.model.Message import chats.domain.repository.ChatRepository import chats.data.repository.toDomain import core.network.ServerConfig +import core.security.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -30,7 +31,8 @@ data class ChatDetailState( class ChatDetailViewModel @Inject constructor( private val repository: ChatRepository, private val signalrClient: ChatHubClient, - private val serverConfig: ServerConfig + private val serverConfig: ServerConfig, + private val tokenManager: TokenManager ) : ViewModel() { private val _state = MutableStateFlow(ChatDetailState()) @@ -48,6 +50,10 @@ class ChatDetailViewModel @Inject constructor( ) } } + fun getCurrentUserId(): String { + return tokenManager.getUserId() ?: "" + } + fun setChatId(chatId: String) { currentChatId = chatId loadMessages(chatId) @@ -58,7 +64,9 @@ class ChatDetailViewModel @Inject constructor( viewModelScope.launch { _state.update { it.copy(isLoading = true) } try { - val messages = repository.getMessages(chatId) + // Бэкенд обычно возвращает сообщения от новых к старым. + // Для чата нам нужно наоборот: старые вверху, новые внизу. + val messages = repository.getMessages(chatId).reversed() _state.update { it.copy(messages = messages, isLoading = false) } } catch (e: Exception) { _state.update { it.copy(isLoading = false, error = e.localizedMessage) } @@ -146,6 +154,15 @@ class ChatDetailViewModel @Inject constructor( _state.update { it.copy(error = "File too large") } return } - // Upload logic... + viewModelScope.launch { + try { + val url = repository.uploadMedia(file) + // After upload, we might want to send a message with this media + // For now, let's just log it or handle as per app requirements + } catch (e: Exception) { + _state.update { it.copy(error = e.localizedMessage) } + } + } } } + diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt index ce88bbf..39214a4 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt @@ -7,6 +7,7 @@ import chats.domain.repository.ChatRepository import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatEvent import core.network.ServerConfig +import core.security.TokenManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -24,7 +25,8 @@ data class ChatListState( class ChatListViewModel @Inject constructor( private val repository: ChatRepository, private val signalrClient: ChatHubClient, - private val serverConfig: ServerConfig + private val serverConfig: ServerConfig, + private val tokenManager: TokenManager ) : ViewModel() { private val _state = MutableStateFlow(ChatListState()) @@ -41,6 +43,8 @@ class ChatListViewModel @Inject constructor( observeSignalREvents() } + private fun getCurrentUserId(): String = tokenManager.getUserId() ?: "" + fun loadChats() { viewModelScope.launch { _state.update { it.copy(isLoading = true) } @@ -61,7 +65,8 @@ class ChatListViewModel @Inject constructor( updateChatsWithNewMessage(event) } is ChatEvent.NewChat -> { - _state.update { it.copy(chats = listOf(event.chat.toDomain()) + it.chats) } + val currentUserId = getCurrentUserId() + _state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId)) + it.chats) } } else -> Unit } @@ -69,6 +74,7 @@ class ChatListViewModel @Inject constructor( .launchIn(viewModelScope) } + private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) { _state.update { currentState -> val updatedChats = currentState.chats.map { chat -> diff --git a/client-mobile/chats/presentation/components/ChatItem.kt b/client-mobile/chats/presentation/components/ChatItem.kt index 621559d..e2ef497 100644 --- a/client-mobile/chats/presentation/components/ChatItem.kt +++ b/client-mobile/chats/presentation/components/ChatItem.kt @@ -17,6 +17,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chats.domain.model.Chat +import androidx.compose.foundation.shape.RoundedCornerShape +import coil.compose.AsyncImage +import androidx.compose.ui.layout.ContentScale + @Composable fun ChatItem( chat: Chat, @@ -29,18 +33,27 @@ fun ChatItem( .padding(12.dp), verticalAlignment = Alignment.CenterVertically ) { - // Заглушка аватара (в реальном приложении используем Coil для URL) + // Аватар (мягкий квадрат) Box( modifier = Modifier .size(50.dp) - .clip(CircleShape) + .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center ) { - Text( - text = chat.name.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium - ) + if (chat.avatar != null) { + AsyncImage( + model = chat.avatar, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Text( + text = chat.name.take(1).uppercase(), + style = MaterialTheme.typography.titleMedium + ) + } } Spacer(modifier = Modifier.width(12.dp)) diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index f3b3575..9c3f4b1 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -38,22 +38,18 @@ fun MessageBubble( var showReactionPicker by remember { mutableStateOf(false) } val backgroundColor = if (isCurrentUser) { - MaterialTheme.colorScheme.primary + Color(0xFF3390EC) // Telegram Blue } else { - MaterialTheme.colorScheme.surfaceVariant + Color(0xFF2B2B2B) // Dark Grey } - val contentColor = if (isCurrentUser) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } + val contentColor = Color.White val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart val shape = if (isCurrentUser) { - RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) + RoundedCornerShape(12.dp, 12.dp, 4.dp, 12.dp) } else { - RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp) + RoundedCornerShape(12.dp, 12.dp, 12.dp, 4.dp) } val isVoiceMessage = message.mediaType == MediaType.AUDIO && diff --git a/client-mobile/core/security/TokenManager.kt b/client-mobile/core/security/TokenManager.kt index 262fc76..abc4c4e 100644 --- a/client-mobile/core/security/TokenManager.kt +++ b/client-mobile/core/security/TokenManager.kt @@ -20,15 +20,23 @@ class TokenManager @Inject constructor(context: Context) { EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) - fun saveToken(token: String) { - prefs.edit().putString("jwt_token", token).apply() + fun saveToken(token: String, userId: String? = null) { + val editor = prefs.edit().putString("jwt_token", token) + if (userId != null) { + editor.putString("user_id", userId) + } + editor.apply() } fun getToken(): String? { return prefs.getString("jwt_token", null) } + fun getUserId(): String? { + return prefs.getString("user_id", null) + } + fun deleteToken() { - prefs.edit().remove("jwt_token").apply() + prefs.edit().remove("jwt_token").remove("user_id").apply() } } diff --git a/client-mobile/navigation/AppNavigation.kt b/client-mobile/navigation/AppNavigation.kt index 1f5bb8f..df77805 100644 --- a/client-mobile/navigation/AppNavigation.kt +++ b/client-mobile/navigation/AppNavigation.kt @@ -51,9 +51,12 @@ sealed class Screen(val route: String) { @Composable fun AppNavigation( - navController: NavHostController = rememberNavController(), - startDestination: String = Screen.Login.route + navController: NavHostController = rememberNavController() ) { + val authViewModel: AuthViewModel = hiltViewModel() + val authState by authViewModel.state.collectAsState() + val startDestination = if (authState.isAuthenticated) Screen.ChatList.route else Screen.Login.route + val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route @@ -137,6 +140,7 @@ fun AppNavigation( val viewModel: ChatDetailViewModel = hiltViewModel() ChatDetailScreen( + chatId = chatId, chatName = chatName, viewModel = viewModel, onBack = { navController.popBackStack() }