Событие печати, смена адреса, иконки

This commit is contained in:
Халимов Рустам
2026-04-17 16:23:52 +03:00
parent cea3f4d669
commit 8c00d1376d
20 changed files with 95 additions and 26 deletions

View File

@@ -11,9 +11,12 @@
<application
android:name="com.knot.messenger.MainApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.KnotMessenger">
android:theme="@style/Theme.KnotMessenger"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<activity
android:name="com.knot.messenger.MainActivity"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Для отладки: доверяем пользовательским сертификатам -->
<debug-overrides>
<trust-anchors>
<certificates src="user" />
<certificates src="system" />
</trust-anchors>
</debug-overrides>
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.0.0/8</domain>
<domain includeSubdomains="true">172.16.0.0/12</domain>
<domain includeSubdomains="true">192.168.0.0/16</domain>
</domain-config>
</network-security-config>

View File

@@ -107,6 +107,10 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_stopped_typing", { data: ReactionEvent ->
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java)
conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java)
@@ -181,4 +185,18 @@ class ChatHubClient @Inject constructor() {
}
}
}
fun sendTypingIndicator(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.send("typing_start", chatId)
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
}
}
fun sendUserStoppedTyping(chatId: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
hubConnection?.send("typing_stop", chatId)
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
}
}
}

View File

@@ -100,9 +100,9 @@ class ChatDetailViewModel @Inject constructor(
// Ensure SignalR is connected and join the chat room
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl()
signalrClient.connect(baseUrl, token)
val baseUrl = serverConfig.getBaseUrl()
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
signalrClient.joinChat(chatId)
}
@@ -230,6 +230,9 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(isTyping = false) }
}
}
is ChatEvent.UserStoppedTyping -> {
_state.update { it.copy(isTyping = false) }
}
is ChatEvent.MessagesRead -> {
_state.update { currentState ->
val updatedMessages = currentState.messages.map { msg ->
@@ -285,6 +288,22 @@ class ChatDetailViewModel @Inject constructor(
fun onInputTextChanged(text: String) {
_state.update { it.copy(inputText = text) }
val chatId = currentChatId ?: return
// Отправляем индикатор набора текста
val now = System.currentTimeMillis()
if (now - lastTypingSentTime > 1000) {
signalrClient.sendTypingIndicator(chatId)
lastTypingSentTime = now
// Отправляем "остановился печатать" через 3 секунды
typingTimerJob?.cancel()
typingTimerJob = viewModelScope.launch {
delay(3000)
signalrClient.sendUserStoppedTyping(chatId)
}
}
}
fun sendMessage(onFail: (String) -> Unit = {}) {
@@ -298,7 +317,7 @@ class ChatDetailViewModel @Inject constructor(
val tempId = "temp_${System.currentTimeMillis()}"
val userId = getCurrentUserId()
// Determine mediaType based on attachments
val mediaType = when {
pending.isEmpty() -> chats.domain.model.MediaType.TEXT

View File

@@ -44,9 +44,11 @@ class ChatListViewModel @Inject constructor(
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
signalrClient.connect(baseUrl, token)
val baseUrl = serverConfig.getBaseUrl()
// Подключаемся к SignalR только если есть токен И введён URL сервера
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
}
loadChats()

View File

@@ -1,7 +1,6 @@
package core.network
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
import core.domain.model.FeaturesConfig
import core.domain.model.ServerConfigModel
@@ -13,12 +12,12 @@ class ServerConfig @Inject constructor(
private val context: Context,
private val gson: Gson
) {
private val prefs: SharedPreferences = context.getSharedPreferences("server_settings", Context.MODE_PRIVATE)
private val prefs = context.getSharedPreferences("server_config_prefs", Context.MODE_PRIVATE)
companion object {
const val DEFAULT_BASE_URL = "https://your-api.com/api/"
private const val KEY_BASE_URL = "base_url"
private const val KEY_CONFIG_DATA = "config_data"
private const val KEY_CONFIG_DATA = "server_config_data"
private const val DEFAULT_BASE_URL = "" // Пустой по умолчанию
}
fun getBaseUrl(): String {
@@ -48,3 +47,4 @@ class ServerConfig @Inject constructor(
return feature(getServerConfig().features)
}
}

View File

@@ -32,18 +32,7 @@ fun SettingsScreen(
val state by viewModel.state.collectAsState()
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = androidx.lifecycle.LifecycleEventObserver { _, event ->
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
viewModel.loadConfig()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
// Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh
Scaffold(
containerColor = Color(0xFF0F0F10),
@@ -110,7 +99,9 @@ fun SettingsScreen(
focusedIndicatorColor = Color(0xFF3390EC),
unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f)
),
placeholder = { Text("https://...", color = Color.Gray) }
placeholder = {
Text("Например: http://192.168.1.100:5034/api/", color = Color.Gray.copy(alpha = 0.5f))
}
)
IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC))

View File

@@ -31,7 +31,7 @@ class SettingsViewModel @Inject constructor(
init {
refreshState()
loadConfig()
// loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён
}
private fun refreshState() {
@@ -44,6 +44,13 @@ class SettingsViewModel @Inject constructor(
}
fun loadConfig() {
// Не загружаем конфиг, если URL не введён
val currentUrl = serverConfig.getBaseUrl()
if (currentUrl.isBlank()) {
android.util.Log.d("SettingsViewModel", "Base URL is empty, skipping config load")
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = authRepository.fetchConfig()
@@ -58,6 +65,16 @@ class SettingsViewModel @Inject constructor(
}
fun saveBaseUrl(url: String) {
val currentUrl = serverConfig.getBaseUrl()
// Если URL действительно изменился, очищаем токен и логируем
if (currentUrl != url) {
android.util.Log.d("SettingsViewModel", "Server URL changed: $currentUrl -> $url, clearing token")
viewModelScope.launch {
authRepository.logout()
}
}
serverConfig.setBaseUrl(url)
_state.update { it.copy(baseUrl = url) }
}