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

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

View File

@@ -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<Unit> {
return try {
val config = api.getConfig()

View File

@@ -7,4 +7,5 @@ interface AuthRepository {
suspend fun register(userName: String, password: String): Result<AuthResult>
suspend fun logout()
suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean
}

View File

@@ -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<AuthState> = _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) }

View File

@@ -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<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 {
@GET("chats")
suspend fun getChats(): List<ChatDto>
@@ -16,7 +31,7 @@ interface ChatApi {
): List<MessageDto>
@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")

View File

@@ -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<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?
)

View File

@@ -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<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> {
@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}
}
) {

View File

@@ -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) }
}
}
}
}

View File

@@ -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 ->

View File

@@ -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))

View File

@@ -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 &&

View File

@@ -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()
}
}

View File

@@ -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() }