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

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

@@ -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>
@@ -107,6 +107,10 @@ class ChatHubClient @Inject constructor() {
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: "")) _events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
}, ReactionEvent::class.java) }, 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 -> conn.on("user_online", { userId: String ->
_events.tryEmit(ChatEvent.UserOnline(userId)) _events.tryEmit(ChatEvent.UserOnline(userId))
}, String::class.java) }, 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")
}
}
} }
@@ -100,9 +100,9 @@ class ChatDetailViewModel @Inject constructor(
// Ensure SignalR is connected and join the chat room // Ensure SignalR is connected and join the chat room
val token = tokenManager.getToken() val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl() val baseUrl = serverConfig.getBaseUrl()
signalrClient.connect(baseUrl, token) if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
signalrClient.joinChat(chatId) signalrClient.joinChat(chatId)
} }
@@ -230,6 +230,9 @@ class ChatDetailViewModel @Inject constructor(
_state.update { it.copy(isTyping = false) } _state.update { it.copy(isTyping = false) }
} }
} }
is ChatEvent.UserStoppedTyping -> {
_state.update { it.copy(isTyping = false) }
}
is ChatEvent.MessagesRead -> { is ChatEvent.MessagesRead -> {
_state.update { currentState -> _state.update { currentState ->
val updatedMessages = currentState.messages.map { msg -> val updatedMessages = currentState.messages.map { msg ->
@@ -285,6 +288,22 @@ class ChatDetailViewModel @Inject constructor(
fun onInputTextChanged(text: String) { fun onInputTextChanged(text: String) {
_state.update { it.copy(inputText = text) } _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 = {}) { fun sendMessage(onFail: (String) -> Unit = {}) {
@@ -44,9 +44,11 @@ class ChatListViewModel @Inject constructor(
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) } _state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
val token = tokenManager.getToken() val token = tokenManager.getToken()
if (token != null) { val baseUrl = serverConfig.getBaseUrl()
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
signalrClient.connect(baseUrl, token) // Подключаемся к SignalR только если есть токен И введён URL сервера
if (token != null && baseUrl.isNotBlank()) {
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
} }
loadChats() loadChats()
+4 -4
View File
@@ -1,7 +1,6 @@
package core.network package core.network
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson import com.google.gson.Gson
import core.domain.model.FeaturesConfig import core.domain.model.FeaturesConfig
import core.domain.model.ServerConfigModel import core.domain.model.ServerConfigModel
@@ -13,12 +12,12 @@ class ServerConfig @Inject constructor(
private val context: Context, private val context: Context,
private val gson: Gson 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 { companion object {
const val DEFAULT_BASE_URL = "https://your-api.com/api/"
private const val KEY_BASE_URL = "base_url" 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 { fun getBaseUrl(): String {
@@ -48,3 +47,4 @@ class ServerConfig @Inject constructor(
return feature(getServerConfig().features) return feature(getServerConfig().features)
} }
} }
@@ -32,18 +32,7 @@ fun SettingsScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) } var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) }
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current // Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh
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)
}
}
Scaffold( Scaffold(
containerColor = Color(0xFF0F0F10), containerColor = Color(0xFF0F0F10),
@@ -110,7 +99,9 @@ fun SettingsScreen(
focusedIndicatorColor = Color(0xFF3390EC), focusedIndicatorColor = Color(0xFF3390EC),
unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f) 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) }) { IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) {
Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC)) Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC))
@@ -31,7 +31,7 @@ class SettingsViewModel @Inject constructor(
init { init {
refreshState() refreshState()
loadConfig() // loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён
} }
private fun refreshState() { private fun refreshState() {
@@ -44,6 +44,13 @@ class SettingsViewModel @Inject constructor(
} }
fun loadConfig() { 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 { viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) } _state.update { it.copy(isLoading = true, error = null) }
val result = authRepository.fetchConfig() val result = authRepository.fetchConfig()
@@ -58,6 +65,16 @@ class SettingsViewModel @Inject constructor(
} }
fun saveBaseUrl(url: String) { 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) serverConfig.setBaseUrl(url)
_state.update { it.copy(baseUrl = url) } _state.update { it.copy(baseUrl = url) }
} }