Авторизация, нерабочий чат
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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?
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
) {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
Reference in New Issue
Block a user