Авторизация, нерабочий чат

This commit is contained in:
Халимов Рустам
2026-04-14 01:41:51 +03:00
parent 1fb1be47dd
commit dc051fa9ae
22 changed files with 160 additions and 49 deletions
Binary file not shown.
Binary file not shown.
@@ -18,13 +18,14 @@ class AuthRepositoryImpl @Inject constructor(
return try { return try {
val response = api.login(AuthRequest(userName, password)) val response = api.login(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null")) val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token) tokenManager.saveToken(token, userId)
fetchConfig() fetchConfig()
Result.success( Result.success(
AuthResult( AuthResult(
token = token, token = token,
userId = response.userId ?: "", userId = userId,
userName = response.username ?: userName, userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName, displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null avatarUrl = null
@@ -39,13 +40,14 @@ class AuthRepositoryImpl @Inject constructor(
return try { return try {
val response = api.register(AuthRequest(userName, password)) val response = api.register(AuthRequest(userName, password))
val token = response.accessToken ?: return Result.failure(Exception("Token is null")) val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
val userId = response.userId ?: ""
tokenManager.saveToken(token) tokenManager.saveToken(token, userId)
fetchConfig() fetchConfig()
Result.success( Result.success(
AuthResult( AuthResult(
token = token, token = token,
userId = response.userId ?: "", userId = userId,
userName = response.username ?: userName, userName = response.username ?: userName,
displayName = response.displayName ?: response.username ?: userName, displayName = response.displayName ?: response.username ?: userName,
avatarUrl = null avatarUrl = null
@@ -60,6 +62,10 @@ class AuthRepositoryImpl @Inject constructor(
tokenManager.deleteToken() tokenManager.deleteToken()
} }
override fun isAuthenticated(): Boolean {
return tokenManager.getToken() != null
}
override suspend fun fetchConfig(): Result<Unit> { override suspend fun fetchConfig(): Result<Unit> {
return try { return try {
val config = api.getConfig() val config = api.getConfig()
@@ -7,4 +7,5 @@ interface AuthRepository {
suspend fun register(userName: String, password: String): Result<AuthResult> suspend fun register(userName: String, password: String): Result<AuthResult>
suspend fun logout() suspend fun logout()
suspend fun fetchConfig(): Result<Unit> suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean
} }
@@ -22,9 +22,13 @@ class AuthViewModel @Inject constructor(
private val repository: AuthRepository private val repository: AuthRepository
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(AuthState()) private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
val state: StateFlow<AuthState> = _state.asStateFlow() val state: StateFlow<AuthState> = _state.asStateFlow()
fun checkAuth() {
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
}
fun login(userName: String, password: String) { fun login(userName: String, password: String) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
+16 -1
View File
@@ -4,6 +4,21 @@ import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto import chats.data.remote.dto.MessageDto
import retrofit2.http.* import retrofit2.http.*
data class SendMessageRequest(
val content: String?,
val type: String = "text",
val attachments: List<AttachmentRequest>? = 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 { interface ChatApi {
@GET("chats") @GET("chats")
suspend fun getChats(): List<ChatDto> suspend fun getChats(): List<ChatDto>
@@ -16,7 +31,7 @@ interface ChatApi {
): List<MessageDto> ): List<MessageDto>
@POST("messages/chat/{chatId}") @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 @Multipart
@POST("messages/upload") @POST("messages/upload")
@@ -31,6 +31,13 @@ data class ChatDto(
@SerializedName("id") val id: String, @SerializedName("id") val id: String,
@SerializedName("type") val type: String, @SerializedName("type") val type: String,
@SerializedName("name") val name: String?, @SerializedName("name") val name: String?,
@SerializedName("avatar") val avatar: String?,
@SerializedName("unreadCount") val unreadCount: Int, @SerializedName("unreadCount") val unreadCount: Int,
@SerializedName("messages") val messages: List<MessageDto> @SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)
data class ChatMemberDto(
@SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto?
) )
@@ -1,20 +1,27 @@
package chats.data.repository package chats.data.repository
import chats.data.remote.api.ChatApi import chats.data.remote.api.ChatApi
import chats.data.remote.api.SendMessageRequest
import chats.data.remote.dto.ChatDto import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto import chats.data.remote.dto.MessageDto
import chats.domain.model.Chat import chats.domain.model.Chat
import chats.domain.model.Message import chats.domain.model.Message
import chats.domain.model.MediaType import chats.domain.model.MediaType
import chats.domain.repository.ChatRepository 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 import javax.inject.Inject
class ChatRepositoryImpl @Inject constructor( class ChatRepositoryImpl @Inject constructor(
private val api: ChatApi private val api: ChatApi,
private val tokenManager: TokenManager
) : ChatRepository { ) : ChatRepository {
override suspend fun getChats(): List<Chat> { override suspend fun getChats(): List<Chat> {
return api.getChats().map { it.toDomain() } val currentUserId = tokenManager.getUserId() ?: ""
return api.getChats().map { it.toDomain(currentUserId) }
} }
override suspend fun getMessages(chatId: String): List<Message> { override suspend fun getMessages(chatId: String): List<Message> {
@@ -22,7 +29,8 @@ class ChatRepositoryImpl @Inject constructor(
} }
override suspend fun sendMessage(chatId: String, content: String): Message { 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) { override suspend fun addReaction(messageId: String, emoji: String) {
@@ -36,17 +44,37 @@ class ChatRepositoryImpl @Inject constructor(
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) { override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) {
api.markMessagesAsRead(chatId, lastMessageId) 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 // Mappers
fun ChatDto.toDomain(): Chat = Chat( fun ChatDto.toDomain(currentUserId: String): Chat {
id = id, // Если это личный чат и имя пустое, ищем имя собеседника в списке участников
type = type, val chatName = name ?: if (type == "personal") {
name = name ?: "Unknown Chat", members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
avatar = null, // Update if avatar is added to ChatDto } else "Group Chat"
unreadCount = unreadCount,
lastMessage = messages.firstOrNull()?.toDomain() 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( fun MessageDto.toDomain(): Message = Message(
id = id, id = id,
+3 -2
View File
@@ -4,6 +4,7 @@ import chats.data.remote.api.ChatApi
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.repository.ChatRepositoryImpl import chats.data.repository.ChatRepositoryImpl
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import core.security.TokenManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -23,8 +24,8 @@ object ChatModule {
@Provides @Provides
@Singleton @Singleton
fun provideChatRepository(api: ChatApi): ChatRepository { fun provideChatRepository(api: ChatApi, tokenManager: TokenManager): ChatRepository {
return ChatRepositoryImpl(api) return ChatRepositoryImpl(api, tokenManager)
} }
@Provides @Provides
@@ -10,4 +10,6 @@ interface ChatRepository {
suspend fun addReaction(messageId: String, emoji: String) suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String) suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
suspend fun uploadMedia(file: java.io.File): String
} }
@@ -1,6 +1,5 @@
package chats.presentation.chat_detail package chats.presentation.chat_detail
import android.Manifest
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -23,13 +22,13 @@ import chats.presentation.components.EmojiPicker
import chats.presentation.components.MessageBubble import chats.presentation.components.MessageBubble
import core.utils.VoiceRecorder import core.utils.VoiceRecorder
import core.utils.copyUriToFile import core.utils.copyUriToFile
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import ru.knot.messager.R import ru.knot.messager.R
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChatDetailScreen( fun ChatDetailScreen(
chatId: String,
chatName: String, chatName: String,
viewModel: ChatDetailViewModel, viewModel: ChatDetailViewModel,
onBack: () -> Unit onBack: () -> Unit
@@ -52,6 +51,11 @@ fun ChatDetailScreen(
} }
} }
// Загрузка данных чата при входе
LaunchedEffect(chatId) {
viewModel.setChatId(chatId)
}
// Автопрокрутка к последнему сообщению // Автопрокрутка к последнему сообщению
LaunchedEffect(state.messages.size) { LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) { if (state.messages.isNotEmpty()) {
@@ -112,7 +116,7 @@ fun ChatDetailScreen(
items(state.messages) { message -> items(state.messages) { message ->
MessageBubble( MessageBubble(
message = message, message = message,
isCurrentUser = message.senderId == "CURRENT_USER_ID" // TODO: Get from Auth isCurrentUser = message.senderId == viewModel.getCurrentUserId()
) )
} }
} }
@@ -163,7 +167,6 @@ fun ChatDetailScreen(
} else { } else {
voiceRecorder.stopRecording() voiceRecorder.stopRecording()
isRecording = false isRecording = false
// TODO: Send voice file
} }
} }
) { ) {
@@ -8,6 +8,7 @@ import chats.domain.model.Message
import chats.domain.repository.ChatRepository import chats.domain.repository.ChatRepository
import chats.data.repository.toDomain import chats.data.repository.toDomain
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -30,7 +31,8 @@ data class ChatDetailState(
class ChatDetailViewModel @Inject constructor( class ChatDetailViewModel @Inject constructor(
private val repository: ChatRepository, private val repository: ChatRepository,
private val signalrClient: ChatHubClient, private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(ChatDetailState()) private val _state = MutableStateFlow(ChatDetailState())
@@ -48,6 +50,10 @@ class ChatDetailViewModel @Inject constructor(
) } ) }
} }
fun getCurrentUserId(): String {
return tokenManager.getUserId() ?: ""
}
fun setChatId(chatId: String) { fun setChatId(chatId: String) {
currentChatId = chatId currentChatId = chatId
loadMessages(chatId) loadMessages(chatId)
@@ -58,7 +64,9 @@ class ChatDetailViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true) } _state.update { it.copy(isLoading = true) }
try { try {
val messages = repository.getMessages(chatId) // Бэкенд обычно возвращает сообщения от новых к старым.
// Для чата нам нужно наоборот: старые вверху, новые внизу.
val messages = repository.getMessages(chatId).reversed()
_state.update { it.copy(messages = messages, isLoading = false) } _state.update { it.copy(messages = messages, isLoading = false) }
} catch (e: Exception) { } catch (e: Exception) {
_state.update { it.copy(isLoading = false, error = e.localizedMessage) } _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") } _state.update { it.copy(error = "File too large") }
return 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) }
}
}
} }
} }
@@ -7,6 +7,7 @@ import chats.domain.repository.ChatRepository
import chats.data.remote.signalr.ChatHubClient import chats.data.remote.signalr.ChatHubClient
import chats.data.remote.signalr.ChatEvent import chats.data.remote.signalr.ChatEvent
import core.network.ServerConfig import core.network.ServerConfig
import core.security.TokenManager
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -24,7 +25,8 @@ data class ChatListState(
class ChatListViewModel @Inject constructor( class ChatListViewModel @Inject constructor(
private val repository: ChatRepository, private val repository: ChatRepository,
private val signalrClient: ChatHubClient, private val signalrClient: ChatHubClient,
private val serverConfig: ServerConfig private val serverConfig: ServerConfig,
private val tokenManager: TokenManager
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(ChatListState()) private val _state = MutableStateFlow(ChatListState())
@@ -41,6 +43,8 @@ class ChatListViewModel @Inject constructor(
observeSignalREvents() observeSignalREvents()
} }
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
fun loadChats() { fun loadChats() {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isLoading = true) } _state.update { it.copy(isLoading = true) }
@@ -61,7 +65,8 @@ class ChatListViewModel @Inject constructor(
updateChatsWithNewMessage(event) updateChatsWithNewMessage(event)
} }
is ChatEvent.NewChat -> { 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 else -> Unit
} }
@@ -69,6 +74,7 @@ class ChatListViewModel @Inject constructor(
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) { private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
_state.update { currentState -> _state.update { currentState ->
val updatedChats = currentState.chats.map { chat -> val updatedChats = currentState.chats.map { chat ->
@@ -17,6 +17,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chats.domain.model.Chat import chats.domain.model.Chat
import androidx.compose.foundation.shape.RoundedCornerShape
import coil.compose.AsyncImage
import androidx.compose.ui.layout.ContentScale
@Composable @Composable
fun ChatItem( fun ChatItem(
chat: Chat, chat: Chat,
@@ -29,18 +33,27 @@ fun ChatItem(
.padding(12.dp), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Заглушка аватара (в реальном приложении используем Coil для URL) // Аватар (мягкий квадрат)
Box( Box(
modifier = Modifier modifier = Modifier
.size(50.dp) .size(50.dp)
.clip(CircleShape) .clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primaryContainer), .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( if (chat.avatar != null) {
text = chat.name.take(1).uppercase(), AsyncImage(
style = MaterialTheme.typography.titleMedium 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)) Spacer(modifier = Modifier.width(12.dp))
@@ -38,22 +38,18 @@ fun MessageBubble(
var showReactionPicker by remember { mutableStateOf(false) } var showReactionPicker by remember { mutableStateOf(false) }
val backgroundColor = if (isCurrentUser) { val backgroundColor = if (isCurrentUser) {
MaterialTheme.colorScheme.primary Color(0xFF3390EC) // Telegram Blue
} else { } else {
MaterialTheme.colorScheme.surfaceVariant Color(0xFF2B2B2B) // Dark Grey
} }
val contentColor = if (isCurrentUser) { val contentColor = Color.White
MaterialTheme.colorScheme.onPrimary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) { val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) RoundedCornerShape(12.dp, 12.dp, 4.dp, 12.dp)
} else { } 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 && val isVoiceMessage = message.mediaType == MediaType.AUDIO &&
+11 -3
View File
@@ -20,15 +20,23 @@ class TokenManager @Inject constructor(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
) )
fun saveToken(token: String) { fun saveToken(token: String, userId: String? = null) {
prefs.edit().putString("jwt_token", token).apply() val editor = prefs.edit().putString("jwt_token", token)
if (userId != null) {
editor.putString("user_id", userId)
}
editor.apply()
} }
fun getToken(): String? { fun getToken(): String? {
return prefs.getString("jwt_token", null) return prefs.getString("jwt_token", null)
} }
fun getUserId(): String? {
return prefs.getString("user_id", null)
}
fun deleteToken() { fun deleteToken() {
prefs.edit().remove("jwt_token").apply() prefs.edit().remove("jwt_token").remove("user_id").apply()
} }
} }
+6 -2
View File
@@ -51,9 +51,12 @@ sealed class Screen(val route: String) {
@Composable @Composable
fun AppNavigation( fun AppNavigation(
navController: NavHostController = rememberNavController(), navController: NavHostController = rememberNavController()
startDestination: String = Screen.Login.route
) { ) {
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 navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route val currentRoute = navBackStackEntry?.destination?.route
@@ -137,6 +140,7 @@ fun AppNavigation(
val viewModel: ChatDetailViewModel = hiltViewModel() val viewModel: ChatDetailViewModel = hiltViewModel()
ChatDetailScreen( ChatDetailScreen(
chatId = chatId,
chatName = chatName, chatName = chatName,
viewModel = viewModel, viewModel = viewModel,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() }