Refactor code to support AGP 9.0

This commit is contained in:
2025-12-20 20:53:13 +03:00
Unverified
parent 8151e0ca0d
commit b18af36b3e
133 changed files with 255 additions and 125 deletions
+1
View File
@@ -0,0 +1 @@
/build
+103
View File
@@ -0,0 +1,103 @@
import com.android.build.api.dsl.androidLibrary
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.android.kotlin.multiplatform.library)
kotlin("plugin.serialization") version "1.9.22"
}
kotlin {
androidLibrary {
namespace = "ru.fromchat.shared"
minSdk = 24
compileSdk = 36
}
compilerOptions {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
all {
languageSettings {
optIn("kotlin.RequiresOptIn")
}
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
implementation(libs.constraintlayout)
implementation(libs.navigation.compose)
implementation(compose.materialIconsExtended)
implementation(libs.haze)
implementation(libs.haze.materials)
// Serialization
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.io.core)
// Ktor - force version 2.3.12 to avoid conflicts with Coil 3's Ktor 3
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.client.logging)
// Datetime
implementation(libs.kotlinx.datetime)
// Coil for image loading (multiplatform)
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
implementation(project(":utils:shared"))
}
iosMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.darwin)
}
}
}
compose.resources {
publicResClass = true
packageOfResClass = "ru.fromchat"
generateResClass = auto
}
tasks.register("generateResourceAccessors") {
dependsOn(
*(
tasks.filter {
it.name.startsWith("generateResourceAccessors") &&
!it.name.matches("^(:${project.name})?generateResourceAccessors$".toRegex())
}.toTypedArray()
)
)
}
@@ -0,0 +1,17 @@
package ru.fromchat.api
import android.util.Log
import io.ktor.client.plugins.logging.Logger
/**
* Custom Android logger for Ktor that uses Android's Log system
*/
object AndroidKtorLogger : Logger {
private const val TAG = "Ktor"
override fun log(message: String) {
Log.d(TAG, message)
}
}
@@ -0,0 +1,7 @@
package ru.fromchat.api
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttp
actual fun createPlatformHttpClient(block: HttpClientConfig<*>.() -> Unit) = HttpClient(OkHttp, block)
@@ -0,0 +1,22 @@
package ru.fromchat.core
import android.util.Log
actual object Logger {
actual fun d(tag: String, message: String, throwable: Throwable?) {
Log.d(tag, message, throwable)
}
actual fun i(tag: String, message: String, throwable: Throwable?) {
Log.i(tag, message, throwable)
}
actual fun w(tag: String, message: String, throwable: Throwable?) {
Log.w(tag, message, throwable)
}
actual fun e(tag: String, message: String, throwable: Throwable?) {
Log.e(tag, message, throwable)
}
}
@@ -0,0 +1,19 @@
package ru.fromchat.ui
import android.os.Build
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (dynamicColor && Build.VERSION.SDK_INT >= 31) {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
} else {
if (darkTheme) darkColorScheme()
else lightColorScheme()
}
@@ -0,0 +1,17 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M0 0 h 1024 v 1024 h -1024 L0 0"
android:fillColor="#4484f4"
android:fillType="evenOdd"
android:strokeColor="#00000000" />
<path
android:pathData="M500.1,129.1C497.2,129.6 491.7,131.1 488,132.4C483.9,133.8 427.2,165.8 339.9,216C262.1,260.7 196.7,298.5 194.5,300C188.8,303.9 178.1,315.6 174.2,322.2C170,329.3 166.4,339.6 165,348.5C164.3,353.3 164,407.1 164.2,522L164.5,688.5 167.1,696.2C172.4,711.6 183,726 195.3,734.3C205,741 481.1,890.9 487.3,892.9C504.8,898.6 523.3,897.9 539.4,891.1C547.3,887.7 827.3,721.9 833.7,716.8C841.4,710.7 848.2,701.9 853.1,691.5C860.4,675.9 860,686.3 860,509.5C860,331.6 860.5,344.2 852.5,327.5C846.2,314.4 837,304.3 824.3,296.7C811.8,289.2 549.2,138.1 544.5,135.8C532.1,129.5 513.5,126.7 500.1,129.1M502.5,149.4C502,149.6 499,150.6 496,151.5C489,153.7 208.7,314.7 202.8,320C200.4,322.1 198.7,324 199,324.3C199.3,324.6 225.1,339.5 256.5,357.5C292.1,377.9 313.9,389.9 314.6,389.3C315.3,388.9 317.3,387.3 319.1,385.8C324.2,381.9 492.9,284.6 498.6,282.4C505.5,279.7 519.6,279.9 526.5,282.7C532.9,285.2 702.2,383.2 707.2,387.3L710.9,390.3 768.5,357.4L826.1,324.5 821.6,320.4C816.6,315.8 540.6,156.2 530.5,152.1C525.7,150.2 522.3,149.6 514,149.4C508.2,149.2 503,149.2 502.5,149.4M186.2,347.1C184.6,353.8 184.5,680.2 186.1,687C188.1,696 193,704.2 200.4,711.4C207.3,718.1 211.3,720.4 348.5,795.1C426,837.4 491.4,872.6 493.9,873.5C502.9,876.5 502,883.5 502,809.6L502,744.2 497.7,742.7C493.2,741.2 327.2,650.6 320.4,645.9C314.5,641.9 310.2,636.6 306.6,629L303.5,622.5 303.5,515.3L303.5,408.2 245.8,375.1C214,356.9 187.9,342 187.7,342C187.5,342 186.8,344.3 186.2,347.1M779.1,375L721.8,407.9 721.4,507.7C721.1,562.6 720.7,609.3 720.4,611.6C719.6,618 714.5,627.6 709.2,632.8C705.7,636.1 679.1,652.4 617.9,688.7C570.4,716.9 529.3,740.8 526.7,742L522,744.1 522,809.6L522,875 524.8,874.4C530.2,873.2 543.7,865.4 679.1,785.5C828,697.6 826.1,698.9 832.8,686.3C839.7,673.1 839.3,685.9 839.4,509.5C839.5,370.5 839.3,350 838,346.4L836.5,342.2 779.1,375M504.5,357.1C502.4,357.6 474.2,373.2 442,391.8C378.9,428.2 376.8,429.6 373,439.6C371,445.1 371,446.4 371.2,515.8L371.5,586.5 373.8,591.1C375,593.7 377.8,597.5 380,599.7C385.5,605.1 497.7,666.5 505.1,668.1C511.3,669.4 518.9,668.6 524.3,666C533.3,661.6 639.5,598.2 643.3,594.9C648.3,590.6 651.6,584.3 653.1,576.7C653.7,573.1 654,548.2 653.8,507.8L653.5,444.5 650.2,437.8C648.4,434.2 645.5,429.9 643.7,428.3C640.3,425.4 529.5,361 523.4,358.4C518.9,356.5 509.5,355.8 504.5,357.1"
android:fillColor="#fafbfc"
android:fillType="evenOdd"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,8 @@
<resources>
<!-- Typing Indicators -->
<string name="typing_single">%1$s печатает…</string>
<string name="typing_two">%1$s и %2$s печатают…</string>
<string name="typing_many">%1$s, %2$s и еще %3$d печатают…</string>
</resources>
@@ -0,0 +1,70 @@
<resources>
<!-- App -->
<string name="app_name">FromChat</string>
<!-- Navigation -->
<string name="back">Back</string>
<string name="settings">Settings</string>
<string name="home">Home</string>
<string name="about">About app</string>
<string name="app_desc">A 100% free, open source and private messenger.</string>
<!-- Authentication -->
<string name="welcome">Welcome!</string>
<string name="login">Sign In</string>
<string name="login_d">Sign in to your account</string>
<string name="register">Registration</string>
<string name="register_d">Create a new account</string>
<string name="register_button">Register</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="confirm_password">Confirm Password</string>
<string name="display_name">Display Name</string>
<string name="display_name_error">Display name must be between 1 and 64 characters</string>
<!-- Validation Errors -->
<string name="fill_all_fields">Please fill in all fields</string>
<string name="username_length_error">Username must be between 3 and 20 characters</string>
<string name="password_length_error">Password must be between 5 and 50 characters</string>
<string name="passwords_dont_match">Passwords do not match</string>
<!-- Main Screen -->
<string name="chats">Chats</string>
<string name="contacts">Contacts</string>
<string name="dms">Direct Messages</string>
<string name="coming_soon">Coming soon...</string>
<string name="public_chat">General Chat</string>
<string name="chat_last_mesaage">You: Last message</string>
<string name="message_placeholder">Type a message...</string>
<!-- Server Configuration -->
<string name="server_config_title">Server Configuration</string>
<string name="server_config_subtitle">Enter server details to connect</string>
<string name="server_url_label">Server URL</string>
<string name="server_url_hint">example.com</string>
<string name="https_enabled">Use HTTPS</string>
<string name="save_continue">Save &amp; Continue</string>
<!-- Settings -->
<string name="change_server">Change Server</string>
<string name="change_server_d">Changes the FromChat instance you are connecting to. This will log you out of your account.</string>
<string name="logout">Logout</string>
<string name="materialYou">Enable Material You theming</string>
<string name="materialYou_d">Enable the dynamic theme based on your wallpaper. Works only on Android 12 or higher.</string>
<string name="theme">Theme</string>
<string name="as_system">As system</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<!-- Error Messages -->
<string name="error_unexpected">Unexpected error</string>
<string name="error_invalid_credentials">Invalid username or password</string>
<string name="error_connection">Connection error</string>
<string name="error_unknown">An unknown error occurred</string>
<!-- Typing Indicators -->
<string name="typing_single">%1$s is typing…</string>
<string name="typing_two">%1$s and %2$s are typing…</string>
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
</resources>
@@ -0,0 +1,18 @@
package ru.fromchat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
val DATETIME_FORMAT = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
@@ -0,0 +1,210 @@
package ru.fromchat.api
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.pingInterval
import io.ktor.client.request.bearerAuth
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
import ru.fromchat.core.config.Config
import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds
/**
* Creates a platform-specific HTTP client that supports WebSockets
* The config block is applied to configure plugins like WebSockets, JSON, etc.
*/
expect fun createPlatformHttpClient(
block: io.ktor.client.HttpClientConfig<*>.() -> Unit = {}
): HttpClient
object ApiClient {
val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
val http = createPlatformHttpClient {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
logger = Logger.SIMPLE
level = LogLevel.INFO
}
install(WebSockets) {
pingInterval = 5000.milliseconds // Send a ping every 5 seconds to keep the connection alive
}
}
@Volatile
var token: String? = null
@Volatile
var user: User? = null
suspend fun login(request: LoginRequest) =
http
.post("${Config.apiBaseUrl}/login") {
contentType(ContentType.Application.Json)
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
}
.failOnError()
.body<LoginResponse>()
.also {
token = it.token
user = it.user
}
suspend fun register(request: RegisterRequest) =
http
.post("${Config.apiBaseUrl}/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
.failOnError()
suspend fun getMessages(limit: Int = 50, beforeId: Int? = null) =
http
.get("${Config.apiBaseUrl}/get_messages") {
contentType(ContentType.Application.Json)
bearerAuth(token ?: throw IllegalStateException("Not authenticated"))
parameter("limit", limit)
beforeId?.let { parameter("before_id", it) }
}
.failOnError()
.body<MessagesResponse>()
suspend fun send(message: String) =
http
.post("${Config.apiBaseUrl}/send_message") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(SendMessageRequest(message))
}
.failOnError()
.body<SendMessageResponse>()
suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout
try {
http.get("${Config.apiBaseUrl}/logout") {
bearerAuth(authToken)
}
} catch (e: Exception) {
// Ignore logout errors
}
}
// WebSocket send helpers
suspend fun sendMessage(content: String, replyToId: Int? = null, clientMessageId: String? = null) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send(
WebSocketMessage(
type = "sendMessage",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
),
data = json.encodeToJsonElement(
WebSocketSendMessageRequest(
content = content,
reply_to_id = replyToId,
client_message_id = clientMessageId
)
)
)
)
}
suspend fun editMessage(messageId: Int, content: String) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send(
WebSocketMessage(
type = "editMessage",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
),
data = json.encodeToJsonElement(
WebSocketEditMessageRequest(
message_id = messageId,
content = content
)
)
)
)
}
suspend fun deleteMessage(messageId: Int) {
val token = token ?: throw IllegalStateException("Not authenticated")
WebSocketManager.send(
WebSocketMessage(
type = "deleteMessage",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
),
data = json.encodeToJsonElement(
WebSocketDeleteMessageRequest(
message_id = messageId
)
)
)
)
}
suspend fun sendTyping() {
val token = token ?: throw IllegalStateException("Not authenticated")
try {
WebSocketManager.send(
WebSocketMessage(
type = "typing",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
)
)
)
} catch (e: Exception) {
// Silently ignore if WebSocket is not connected yet
// Typing indicators are not critical
}
}
suspend fun sendStopTyping() {
val token = token ?: throw IllegalStateException("Not authenticated")
try {
WebSocketManager.send(
WebSocketMessage(
type = "stopTyping",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = token
)
)
)
} catch (e: Exception) {
// Silently ignore if WebSocket is not connected yet
// Typing indicators are not critical
}
}
}
@@ -0,0 +1,158 @@
package ru.fromchat.api
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class RegisterRequest(
val username: String,
val display_name: String,
val password: String,
val confirm_password: String
)
@Serializable
data class ErrorResponse(
val detail: String
)
@Serializable
data class User(
val id: Int,
val created_at: String,
val last_seen: String,
val online: Boolean,
val username: String,
val admin: Boolean? = null,
val bio: String? = null,
val profile_picture: String? = null
)
@Serializable
data class LoginResponse(
val user: User,
val token: String
)
@Serializable
data class MessagesResponse(
val status: String,
val messages: List<Message>
)
@Serializable
data class Message(
val id: Int,
val user_id: Int,
val content: String,
val timestamp: String,
val is_read: Boolean,
val is_edited: Boolean,
val username: String,
val profile_picture: String? = null,
val verified: Boolean? = null,
val reply_to: Message? = null,
val client_message_id: String? = null,
val reactions: List<ReactionData>? = null
) {
val utcTimestamp = "${timestamp}Z"
}
@Serializable
data class SendMessageRequest(
val content: String,
val reply_to_id: Int? = null
)
@Serializable
data class EditMessageRequest(
val content: String
)
@Serializable
data class SendMessageResponse(
val status: String,
val message: Message
)
// WebSocket types mirror frontend src/core/types.d.ts
@Serializable
data class WebSocketCredentials(
val scheme: String,
val credentials: String
)
@Serializable
data class WebSocketError(
val code: Int,
val detail: String
)
@Serializable
data class WebSocketMessage(
val type: String,
val credentials: WebSocketCredentials? = null,
val data: JsonElement? = null,
val error: WebSocketError? = null
)
// WebSocket message data types
@Serializable
data class NewMessageData(
val message: Message
)
@Serializable
data class MessageEditedData(
val message: Message
)
@Serializable
data class MessageDeletedData(
val message_id: Int
)
@Serializable
data class TypingData(
val userId: Int,
val username: String
)
// Batched updates message
@Serializable
data class UpdateItem(
val type: String,
val data: JsonElement? = null
)
@Serializable
data class UpdatesMessage(
val type: String,
val seq: Int,
val updates: List<UpdateItem>
)
// WebSocket request types
@Serializable
data class WebSocketSendMessageRequest(
val content: String,
val reply_to_id: Int? = null,
val client_message_id: String? = null
)
@Serializable
data class WebSocketEditMessageRequest(
val message_id: Int,
val content: String
)
@Serializable
data class WebSocketDeleteMessageRequest(
val message_id: Int
)
@@ -0,0 +1,33 @@
package ru.fromchat.api
import ru.fromchat.core.Logger
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
suspend inline fun <Response> apiRequest(
unexpectedError: String,
onError: (String, Exception) -> Unit = { _, _ -> },
onSuccess: (Response) -> Unit = {},
request: suspend () -> Response
): Result<Response> {
try {
val response = request()
onSuccess(response)
return Result.success(response)
} catch (e: ClientRequestException) {
val message = if (e.response.status.value in arrayOf(401, 403)) {
e.response.body<ErrorResponse>().detail
} else {
unexpectedError
}
Logger.e("API", "API request failed: $message", e)
onError(message, e)
return Result.failure(e)
} catch (e: Exception) {
Logger.e("API", "API request failed: ${e.message}", e)
onError(unexpectedError, e)
return Result.failure(e)
}
}
@@ -0,0 +1,43 @@
package ru.fromchat.api
import kotlinx.serialization.Serializable
@Serializable
data class WebSocketUpdatesData(
val seq: Int,
val updates: List<WebSocketMessage>
)
@Serializable
data class ReactionUpdateData(
val message_id: Int,
val emoji: String,
val action: String,
val user_id: Int,
val username: String,
val reactions: List<ReactionData>
)
@Serializable
data class ReactionData(
val emoji: String,
val count: Int,
val users: List<ReactionUser>
)
@Serializable
data class ReactionUser(
val id: Int,
val username: String
)
@Serializable
data class TypingUpdateData(
val userId: Int,
val username: String
)
@Serializable
data class WebSocketAuthMessage(
val token: String
)
@@ -0,0 +1,224 @@
package ru.fromchat.api
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.client.request.url
import io.ktor.http.HttpMethod
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.core.Logger
import ru.fromchat.core.config.Config
import kotlin.concurrent.Volatile
import kotlin.coroutines.suspendCoroutine
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
object WebSocketManager {
// Config
private val scope = CoroutineScope(Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
private val _messages = MutableSharedFlow<WebSocketMessage>(replay = 0, extraBufferCapacity = 64)
val messages = _messages.asSharedFlow()
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
globalHandlers += handler
}
fun removeGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
globalHandlers -= handler
}
// State
@Volatile private var connecting = false
@Volatile private var session: DefaultClientWebSocketSession? = null
/**
* Check if WebSocket is connected
*/
val isConnected get() = session != null
/**
* Wait for WebSocket connection with timeout
*/
@OptIn(ExperimentalTime::class)
suspend fun waitForConnection(timeoutMs: Long = 10000): Boolean {
Logger.d("WebSocketManager", "waitForConnection: session=${session != null}, connecting=$connecting")
if (session != null) return true
val startTime = Clock.System.now().toEpochMilliseconds()
while (session == null && (Clock.System.now().toEpochMilliseconds() - startTime) < timeoutMs) {
delay(100)
}
Logger.d("WebSocketManager", "waitForConnection finished: session=${session != null}")
return session != null
}
fun connect() {
Logger.d("WebSocketManager", "connect() called. current session=${session != null}, connecting=$connecting")
if (connecting) {
Logger.d("WebSocketManager", "connect() ignored: already connecting")
return
}
connecting = true
Logger.d("WebSocketManager", "connecting set to true")
scope.launch {
while (isActive) {
Logger.d("WebSocketManager", "Connection loop active. isActive=$isActive")
try {
val wsUrl = Config.webSocketUrl
Logger.d("WebSocketManager", "Attempting to connect to: $wsUrl")
ApiClient.http.webSocket(
method = HttpMethod.Get,
request = {
url(wsUrl)
}
) {
session = this
connecting = false
Logger.d("WebSocketManager", "WebSocket connected. connecting set to false")
// Send ping message immediately after connection for authentication
ApiClient.token?.let {
Logger.d("WebSocketManager", "Sending WebSocket ping for authentication")
send(WebSocketMessage(type = "ping", credentials = WebSocketCredentials(scheme = "Bearer", credentials = it)))
}
for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue
Logger.d("WebSocketManager", "Received payload: $text")
try {
val jsonTree = json.parseToJsonElement(text)
val messageType = jsonTree.jsonObject["type"]?.jsonPrimitive?.content
val msg = when (messageType) {
"updates" -> {
WebSocketMessage(
type = "updates",
data = jsonTree
)
}
"typing", "stopTyping" -> {
// These messages are expected to be direct, without additional data in the web client
WebSocketMessage(
type = messageType,
data = jsonTree.jsonObject["data"] // Extract data if present
)
}
else -> {
json.decodeFromString<WebSocketMessage>(text)
}
}
globalHandlers.forEach { it(msg) }
_messages.emit(msg)
} catch (e: Throwable) {
Logger.w("WebSocketManager", "Received malformed payload: ${e.message}", e)
// ignore malformed
}
}
}
} catch (e: Throwable) {
Logger.w("WebSocketManager", "An error occurred during WebSocket connection: ${e.message}", e)
connecting = false
session = null
Logger.d("WebSocketManager", "Reconnecting in 3 seconds...")
delay(3000)
} finally {
Logger.w("WebSocketManager", "WebSocket disconnected. session set to null, connecting set to false")
session = null
connecting = false
}
}
}
}
suspend fun send(message: WebSocketMessage) {
// Wait for connection if not connected yet
if (session == null) {
if (!waitForConnection(5000)) {
Logger.w("WebSocketManager", "Cannot send message: no active session after waiting")
throw IllegalStateException("No active WebSocket session")
}
}
val currentSession = session
if (currentSession != null) {
try {
currentSession.send(Frame.Text(json.encodeToString(message)))
} catch (e: Exception) {
Logger.e("WebSocketManager", "Failed to send message: ${e.message}", e)
throw e
}
} else {
Logger.w("WebSocketManager", "Cannot send message: no active session")
throw IllegalStateException("No active WebSocket session")
}
}
@OptIn(DelicateCoroutinesApi::class)
suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? {
Logger.d("WebSocketManager", "WebSocket request: $message")
var handler: ((WebSocketMessage) -> Unit)? = null
return try {
// Check if we have a valid session before sending
if (session == null) {
Logger.w("WebSocketManager", "No active WebSocket session")
return null
}
send(message)
withTimeout(timeoutMs) {
suspendCoroutine { continuation ->
handler = { response ->
// Only process responses that match our request type
if (response.type == message.type) {
continuation.resumeWith(Result.success(response))
removeGlobalMessageHandler(handler!!)
}
}
addGlobalMessageHandler(handler)
}
}
} catch (_: TimeoutCancellationException) {
Logger.w("WebSocketManager", "Request timed out")
null
} catch (e: Exception) {
Logger.e("WebSocketManager", "Request failed: ${e.message}", e)
null
} finally {
handler?.let { removeGlobalMessageHandler(it) }
}
}
fun shutdown() {
Logger.d("WebSocketManager", "shutdown() called. Cancelling scope.")
scope.cancel()
}
fun disconnect() {
Logger.d("WebSocketManager", "disconnect() called. current session=${session != null}")
session?.cancel() // Close the WebSocket session
session = null
connecting = false
Logger.d("WebSocketManager", "Disconnected. session set to null, connecting set to false")
}
}
@@ -0,0 +1,9 @@
package ru.fromchat.core
expect object Logger {
fun d(tag: String, message: String, throwable: Throwable? = null)
fun i(tag: String, message: String, throwable: Throwable? = null)
fun w(tag: String, message: String, throwable: Throwable? = null)
fun e(tag: String, message: String, throwable: Throwable? = null)
}
@@ -0,0 +1,17 @@
package ru.fromchat.core
import ru.fromchat.ui.Theme
import com.pr0gramm3r101.utils.settings.Settings
object Settings {
private val settings = Settings.Companion()
var materialYou: Boolean
get() = settings.getBoolean("materialYou", true)
set(value) = settings.putBoolean("materialYou", value)
var theme: Theme
get() = Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)]
set(value) = settings.putInt("theme", value.ordinal)
}
@@ -0,0 +1,50 @@
package ru.fromchat.core.config
import com.pr0gramm3r101.utils.storage.ServerConfigData
import com.pr0gramm3r101.utils.storage.ServerConfigStorage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Application configuration
*/
object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
private val config
get() = _serverConfig.value ?: throw IllegalStateException("Server configuration not initialized")
/**
* Initialize configuration by loading from storage
*/
suspend fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig()
}
/**
* Update server configuration
*/
suspend fun updateServerConfig(config: ServerConfigData) {
ServerConfigStorage.saveConfig(config)
_serverConfig.value = config
}
/**
* Get API base URL based on current server configuration
*/
val apiBaseUrl
get() = "${if (config.httpsEnabled) "https" else "http"}://${config.serverUrl}/api"
/**
* Get WebSocket URL based on current server configuration
*/
val webSocketUrl
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
}
@@ -0,0 +1,105 @@
@file:Suppress("NOTHING_TO_INLINE")
package ru.fromchat.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.app_desc
import ru.fromchat.app_icon
import ru.fromchat.app_name
import ru.fromchat.back
@OptIn(ExperimentalMaterial3Api::class)
@Composable
inline fun AboutScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(
stringResource(Res.string.about),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
},
) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
Column(
Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(Modifier.padding(bottom = 10.dp)) {
Image(
painter = painterResource(Res.drawable.app_icon),
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.size(70.dp)
)
}
Text(
text = stringResource(Res.string.app_name),
fontSize = 20.sp,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = stringResource(Res.string.app_desc),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,177 @@
package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.PublicChatScreen
import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("") }
@Composable
fun App() {
var startDestination by remember { mutableStateOf<String?>(null) }
// Initialize config and check server configuration on startup
LaunchedEffect(Unit) {
coroutineScope {
launch {
try {
// Initialize config
Config.initialize()
// Check if server is configured
val serverConfigured = Config.hasServerConfig()
// Determine which screen to show
startDestination = if (!serverConfigured) {
"serverConfig"
} else {
"login"
}
} catch (e: Exception) {
// On error, start with server config
startDestination = "serverConfig"
}
}
}
}
// Observe lifecycle events to manage WebSocket connection
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> {
// Connect WebSocket when app comes to foreground
WebSocketManager.connect()
}
Lifecycle.Event.ON_PAUSE -> {
// Disconnect WebSocket when app goes to background
WebSocketManager.disconnect()
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
FromChatTheme {
val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400)
CompositionLocalProvider(
LocalNavController provides navController
) {
if (startDestination == null) {
Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
}
}
}
}
}
@@ -0,0 +1,37 @@
package ru.fromchat.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@Composable
fun RowHeader(
icon: ImageVector,
title: String,
subtitle: String
) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
androidx.compose.material3.Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier
.padding(bottom = 8.dp)
.size(30.dp),
)
androidx.compose.material3.Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
androidx.compose.material3.Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
}
}
@@ -0,0 +1,129 @@
package ru.fromchat.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import ru.fromchat.core.Settings
enum class Theme {
AsSystem,
Light,
Dark
}
var dynamicThemeEnabled by mutableStateOf(
runCatching { Settings.materialYou }.getOrNull() == true
)
var theme by mutableStateOf(
runCatching { Settings.theme }.getOrNull() ?: Theme.AsSystem
)
@Composable
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
@Composable
fun FromChatTheme(
darkTheme: Boolean = when (theme) {
Theme.AsSystem -> isSystemInDarkTheme()
Theme.Light -> false
Theme.Dark -> true
},
dynamicColor: Boolean = dynamicThemeEnabled,
content: @Composable () -> Unit
) {
var colorScheme = getColorScheme(darkTheme, dynamicColor)
val primary by animateColorAsState(colorScheme.primary)
val onPrimary by animateColorAsState(colorScheme.onPrimary)
val primaryContainer by animateColorAsState(colorScheme.primaryContainer)
val onPrimaryContainer by animateColorAsState(colorScheme.onPrimaryContainer)
val secondary by animateColorAsState(colorScheme.secondary)
val onSecondary by animateColorAsState(colorScheme.onSecondary)
val secondaryContainer by animateColorAsState(colorScheme.secondaryContainer)
val onSecondaryContainer by animateColorAsState(colorScheme.onSecondaryContainer)
val tertiary by animateColorAsState(colorScheme.tertiary)
val onTertiary by animateColorAsState(colorScheme.onTertiary)
val tertiaryContainer by animateColorAsState(colorScheme.tertiaryContainer)
val onTertiaryContainer by animateColorAsState(colorScheme.onTertiaryContainer)
val error by animateColorAsState(colorScheme.error)
val onError by animateColorAsState(colorScheme.onError)
val errorContainer by animateColorAsState(colorScheme.errorContainer)
val onErrorContainer by animateColorAsState(colorScheme.onErrorContainer)
val background by animateColorAsState(colorScheme.background)
val onBackground by animateColorAsState(colorScheme.onBackground)
val surface by animateColorAsState(colorScheme.surface)
val onSurface by animateColorAsState(colorScheme.onSurface)
val surfaceVariant by animateColorAsState(colorScheme.surfaceVariant)
val onSurfaceVariant by animateColorAsState(colorScheme.onSurfaceVariant)
val outline by animateColorAsState(colorScheme.outline)
val outlineVariant by animateColorAsState(colorScheme.outlineVariant)
val scrim by animateColorAsState(colorScheme.scrim)
val inverseSurface by animateColorAsState(colorScheme.inverseSurface)
val inverseOnSurface by animateColorAsState(colorScheme.inverseOnSurface)
val inversePrimary by animateColorAsState(colorScheme.inversePrimary)
val surfaceDim by animateColorAsState(colorScheme.surfaceDim)
val surfaceBright by animateColorAsState(colorScheme.surfaceBright)
val surfaceContainerLowest by animateColorAsState(colorScheme.surfaceContainerLowest)
val surfaceContainerLow by animateColorAsState(colorScheme.surfaceContainerLow)
val surfaceContainer by animateColorAsState(colorScheme.surfaceContainer)
val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh)
val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest)
colorScheme = colorScheme.copy(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
error = error,
onError = onError,
errorContainer = errorContainer,
onErrorContainer = onErrorContainer,
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onSurface,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurfaceVariant,
outline = outline,
outlineVariant = outlineVariant,
scrim = scrim,
inverseSurface = inverseSurface,
inverseOnSurface = inverseOnSurface,
inversePrimary = inversePrimary,
surfaceDim = surfaceDim,
surfaceBright = surfaceBright,
surfaceContainerLowest = surfaceContainerLowest,
surfaceContainerLow = surfaceContainerLow,
surfaceContainer = surfaceContainer,
surfaceContainerHigh = surfaceContainerHigh,
surfaceContainerHighest = surfaceContainerHighest
)
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@Composable
fun isAppInDarkTheme(): Boolean {
return when (theme) {
Theme.AsSystem -> isSystemInDarkTheme()
Theme.Light -> false
Theme.Dark -> true
}
}
@@ -0,0 +1,135 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields
import ru.fromchat.login
import ru.fromchat.login_d
import ru.fromchat.password
import ru.fromchat.register_button
import ru.fromchat.ui.RowHeader
import ru.fromchat.username
import ru.fromchat.welcome
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
val errorUnexpected = stringResource(Res.string.error_unexpected)
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier
.padding(16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
RowHeader(
icon = Icons.AutoMirrored.Filled.Login,
title = stringResource(Res.string.welcome),
subtitle = stringResource(Res.string.login_d)
)
if (alert != null) {
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
}
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.username)) },
singleLine = true
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
Button(
onClick = {
if (username.isBlank() || password.isBlank()) {
alert = alertErrorFilling
return@Button
}
// Derive auth secret before sending (matches frontend implementation)
scope.launch {
val derived = deriveAuthSecret(username.trim(), password.trim())
apiRequest(
unexpectedError = errorUnexpected,
onError = { message, _ ->
alert = message
},
onSuccess = { onLoginSuccess() }
) {
ApiClient.login(LoginRequest(username.trim(), derived))
}
}
}
) {
Text(stringResource(Res.string.login))
}
Button(onClick = onNavigateToRegister) {
Text(stringResource(Res.string.register_button))
}
}
}
}
}
}
@@ -0,0 +1,195 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.RegisterRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.back
import ru.fromchat.confirm_password
import ru.fromchat.display_name
import ru.fromchat.display_name_error
import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields
import ru.fromchat.password
import ru.fromchat.password_length_error
import ru.fromchat.passwords_dont_match
import ru.fromchat.register
import ru.fromchat.register_button
import ru.fromchat.register_d
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader
import ru.fromchat.username
import ru.fromchat.username_length_error
@Composable
fun RegisterScreen(
onRegistered: () -> Unit
) {
val errorUnexpected = stringResource(Res.string.error_unexpected)
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
var username by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(
modifier = Modifier
.padding(16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
RowHeader(
icon = Icons.Filled.PersonAdd,
title = stringResource(Res.string.register),
subtitle = stringResource(Res.string.register_d)
)
if (alert != null) {
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
}
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(Res.string.username)) },
singleLine = true
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text(stringResource(Res.string.display_name)) },
singleLine = true
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(Res.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(Res.string.confirm_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
val alertErrorPasswordLittle = stringResource(Res.string.password_length_error)
val alertErrorNameLittle = stringResource(Res.string.username_length_error)
val alertErrorPasswordConfrim = stringResource(Res.string.passwords_dont_match)
val alertErrorDisplayName = stringResource(Res.string.display_name_error)
IconButton(
onClick = { navController.navigateUp() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
Button(
onClick = {
// Checks
if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
alert = alertErrorFilling
return@Button
}
if (password != confirmPassword) {
alert = alertErrorPasswordConfrim
return@Button
}
if (username.length !in 3..20) {
alert = alertErrorNameLittle
return@Button
}
if (displayName.isBlank() || displayName.length > 64) {
alert = alertErrorDisplayName
return@Button
}
if (password.length !in 5..50) {
alert = alertErrorPasswordLittle
return@Button
}
// Derive auth secret before sending (matches frontend implementation)
scope.launch {
val derived = deriveAuthSecret(username.trim(), password)
apiRequest(
unexpectedError = errorUnexpected,
onError = { message, _ ->
alert = message
},
onSuccess = { onRegistered() }
) {
ApiClient.register(
RegisterRequest(
username.trim(),
displayName.trim(),
derived,
derived
)
)
}
}
}
) {
Text(stringResource(Res.string.register_button))
}
}
}
}
}
}
@@ -0,0 +1,80 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import ru.fromchat.core.config.Config
@Composable
fun Avatar(
profilePictureUrl: String?,
displayName: String,
size: Dp = 32.dp,
modifier: Modifier = Modifier
) {
var imageLoadFailed by remember { mutableStateOf(false) }
val gradient = remember(displayName) { generateGradientFromName(displayName) }
val initials = remember(displayName) { getInitials(displayName) }
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(gradient),
contentAlignment = Alignment.Center
) {
if (profilePictureUrl != null && !imageLoadFailed) {
val fullUrl = if (profilePictureUrl.startsWith("http")) {
profilePictureUrl
} else {
"${Config.apiBaseUrl}$profilePictureUrl"
}
AsyncImage(
model = fullUrl,
contentDescription = displayName,
modifier = Modifier
.size(size)
.clip(CircleShape),
contentScale = ContentScale.Crop,
onError = {
imageLoadFailed = true
},
onSuccess = {
imageLoadFailed = false
}
)
}
// Fallback initials
if (imageLoadFailed || profilePictureUrl == null) {
Text(
text = initials,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier
)
}
}
}
@@ -0,0 +1,144 @@
package ru.fromchat.ui.chat
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
/**
* Get gradient brush for own messages
*/
fun getMessageGradient(isDark: Boolean): Brush {
return if (isDark) {
Brush.linearGradient(
colors = listOf(
Color(0xFF9333EA),
Color(0xFF6366F1),
Color(0xFF2F68C5)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
)
} else {
Brush.linearGradient(
colors = listOf(
Color(0xFFB794F6),
Color(0xFF818CF8),
Color(0xFF60A5FA)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
)
}
}
/**
* Get background gradient brushes for chat background
*/
fun getBackgroundGradients(isDark: Boolean): List<Brush> {
return if (isDark) {
listOf(
Brush.radialGradient(
colors = listOf(
Color(0x26DBA1F9),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x26F3B7BE),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x1AD0C1DA),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
radius = 500f
)
)
} else {
listOf(
Brush.radialGradient(
colors = listOf(
Color(0x15B794F6),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x15F3B7BE),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x0DD0C1DA),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
radius = 500f
)
)
}
}
/**
* Generate a consistent gradient from a name for avatar fallback
*/
fun generateGradientFromName(name: String): Brush {
val hash = name.hashCode()
val r = abs(hash % 256)
val g = abs((hash / 256) % 256)
val b = abs((hash / 65536) % 256)
// Create two colors based on hash for gradient
val color1 = Color(
red = (r + 100).coerceIn(0, 255) / 255f,
green = (g + 100).coerceIn(0, 255) / 255f,
blue = (b + 100).coerceIn(0, 255) / 255f
)
val color2 = Color(
red = (r + 50).coerceIn(0, 255) / 255f,
green = (g + 50).coerceIn(0, 255) / 255f,
blue = (b + 50).coerceIn(0, 255) / 255f
)
return Brush.linearGradient(
colors = listOf(color1, color2),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(100f, 100f)
)
}
/**
* Get initials from display name (first 2 words, first letter of each)
*/
fun getInitials(displayName: String): String {
val words = displayName.trim().split("\\s+".toRegex())
return when {
words.isEmpty() -> "?"
words.size == 1 -> {
val word = words[0]
if (word.length >= 2) {
word.take(2).uppercase()
} else {
word.uppercase() + "?"
}
}
else -> {
words.take(2).joinToString("") { it.firstOrNull()?.uppercase() ?: "" }
}
}
}
@@ -0,0 +1,290 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.Message
import ru.fromchat.message_placeholder
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatInput(
text: String,
onTextChange: (String) -> Unit,
onSend: (String) -> Unit,
typingHandler: TypingHandler,
replyTo: Message? = null,
editingMessage: Message? = null,
onClearReply: () -> Unit,
onClearEdit: () -> Unit,
hazeState: HazeState
) {
val scope = rememberCoroutineScope()
var typingJob by remember { mutableStateOf<kotlinx.coroutines.Job?>(null) }
// Handle typing indicator
LaunchedEffect(text) {
if (text.isNotBlank()) {
typingJob?.cancel()
typingHandler.sendTyping()
typingJob = scope.launch {
delay(3000) // 3 seconds
typingHandler.stopTyping()
}
} else {
typingJob?.cancel()
typingHandler.stopTyping()
}
}
Column(
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
) {
// Reply preview
replyTo?.let { reply ->
ReplyPreviewBar(
replyTo = reply,
onClose = onClearReply,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
// Edit preview
editingMessage?.let { edit ->
EditPreviewBar(
message = edit,
onClose = onClearEdit,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
// Input field with blur
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val shape = RoundedCornerShape(24.dp)
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.weight(1f)
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
placeholder = {
Text(
text = stringResource(Res.string.message_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = text.isNotBlank(),
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
)
) {
Box(Modifier.padding(end = 5.dp)) {
FilledIconButton(
onClick = {
onSend(text.trim())
onTextChange("")
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
}
}
}
)
}
}
}
}
@Composable
private fun ReplyPreviewBar(
replyTo: Message,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier, // hazeEffect moved to ChatInput where it's called
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Replying to ${replyTo.username}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun EditPreviewBar(
message: Message,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier, // hazeEffect moved to ChatInput where it's called
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Editing message",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = message.content.take(50) + if (message.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -0,0 +1,312 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import ru.fromchat.api.Message
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.core.Logger
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
/**
* State data class for ChatPanel
*/
@Serializable
data class ChatPanelState(
val id: String,
val title: String,
val messages: List<Message> = emptyList(),
val isLoading: Boolean = false,
val hasMoreMessages: Boolean = false,
val isLoadingMore: Boolean = false,
val typingUsers: List<TypingUser> = emptyList()
)
/**
* Abstract base class for chat panels
*/
abstract class ChatPanel(
protected val id: String,
protected val currentUserId: Int?,
protected val scope: CoroutineScope
) {
protected var _state: ChatPanelState = ChatPanelState(
id = id,
title = ""
)
private val pendingMessages = mutableMapOf<String, Pair<Job, Message>>()
private var onStateChange: ((ChatPanelState) -> Unit)? = null
/**
* Set state change callback
*/
fun setOnStateChange(callback: (ChatPanelState) -> Unit) {
onStateChange = callback
}
/**
* Get current state
*/
fun getState(): ChatPanelState = _state.copy()
/**
* Update state
*/
protected fun updateState(updates: (ChatPanelState) -> ChatPanelState) {
_state = updates(_state)
// Notify state change - ensure callback runs on main thread for Compose
val callback = onStateChange
val newState = _state.copy()
Logger.d("ChatPanel", "State updated: messages=${newState.messages.size}, callback=${callback != null}")
if (callback != null) {
scope.launch(Dispatchers.Main) {
Logger.d("ChatPanel", "Calling state change callback with ${newState.messages.size} messages")
callback(newState)
}
}
}
/**
* Add message to list
*/
protected fun addMessage(message: Message) {
val messageExists = _state.messages.any { it.id == message.id }
if (!messageExists) {
Logger.d("ChatPanel", "Adding message: id=${message.id}, content=${message.content.take(50)}")
// Add message and sort by timestamp (ISO 8601 strings sort correctly lexicographically)
updateState { currentState ->
val newMessages = (currentState.messages + message).sortedBy { it.timestamp }
Logger.d("ChatPanel", "Messages count after add: ${newMessages.size}")
currentState.copy(messages = newMessages)
}
} else {
Logger.d("ChatPanel", "Message already exists: id=${message.id}")
}
}
/**
* Update existing message
*/
protected fun updateMessage(messageId: Int, updates: (Message) -> Message) {
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
if (msg.id == messageId) {
updates(msg)
} else {
msg
}
}
)
}
}
/**
* Remove message from list
*/
protected fun removeMessage(messageId: Int) {
updateState { it.copy(messages = it.messages.filter { msg -> msg.id != messageId }) }
}
/**
* Clear all messages
*/
protected fun clearMessages() {
updateState { it.copy(messages = emptyList()) }
}
/**
* Set loading state
*/
protected fun setLoading(loading: Boolean) {
updateState { it.copy(isLoading = loading) }
}
/**
* Set has more messages flag
*/
protected fun setHasMoreMessages(hasMore: Boolean) {
updateState { it.copy(hasMoreMessages = hasMore) }
}
/**
* Set loading more state
*/
protected fun setLoadingMore(loading: Boolean) {
updateState { it.copy(isLoadingMore = loading) }
}
/**
* Handle message confirmation (replace temp message with confirmed)
*/
fun handleMessageConfirmed(tempId: String, confirmedMessage: Message) {
val pending = pendingMessages.remove(tempId)
pending?.first?.cancel()
// Replace temporary message with confirmed one
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
// Check if this is the temp message (negative ID)
if (msg.id < 0) {
// Try to match by content or other criteria
// For now, we'll replace based on tempId stored in pendingMessages
confirmedMessage
} else {
msg
}
}
)
}
}
/**
* Retry failed message
*/
@OptIn(ExperimentalTime::class)
suspend fun retryMessage(messageId: Int) {
val message = _state.messages.find { it.id == messageId } ?: return
// Create new temp ID for retry
val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
// Create temp message for retry
val tempMessage = message.copy(id = -1)
// Update message to sending state
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
if (msg.id == messageId) {
tempMessage
} else {
msg
}
}
)
}
// Set up timeout
val timeoutJob = scope.launch {
delay(10000) // 10 seconds
handleMessageTimeout(tempId)
}
pendingMessages[tempId] = timeoutJob to tempMessage
// Retry sending
try {
// Extract content from message
sendMessage(message.content, message.reply_to?.id, message.client_message_id)
} catch (e: Exception) {
timeoutJob.cancel()
pendingMessages.remove(tempId)
// Mark as failed
updateMessage(-1) { it.copy() }
}
}
/**
* Handle message timeout
*/
private fun handleMessageTimeout(tempId: String) {
val pending = pendingMessages.remove(tempId)
pending?.first?.cancel()
// Mark message as failed
updateState { currentState ->
currentState.copy(
messages = currentState.messages.map { msg ->
if (msg.id < 0) {
// Mark as failed - we'll need to add a status field to Message
// For now, just keep it
msg
} else {
msg
}
}
)
}
}
/**
* Delete message immediately from UI
*/
protected fun deleteMessageImmediately(messageId: Int) {
removeMessage(messageId)
}
/**
* Send message with immediate display (optimistic update)
*/
@OptIn(ExperimentalTime::class)
suspend fun sendMessageWithImmediateDisplay(content: String, replyToId: Int?) {
if (content.isBlank()) return
// Create temporary message for immediate display
val tempId = "temp_${Clock.System.now().toEpochMilliseconds()}_${(0..999999).random()}"
val tempMessage = Message(
id = -1, // Temporary negative ID
user_id = currentUserId ?: -1,
content = content.trim(),
timestamp = Clock.System.now().toString(),
is_read = false,
is_edited = false,
username = "You",
reply_to = replyToId?.let { replyId ->
_state.messages.find { it.id == replyId }
}
)
// Add message immediately
addMessage(tempMessage)
// Set up timeout for failure
val timeoutJob = scope.launch {
delay(10000) // 10 seconds timeout
handleMessageTimeout(tempId)
}
// Store pending message
pendingMessages[tempId] = timeoutJob to tempMessage
// Actually send the message
try {
sendMessage(content, replyToId, tempId)
// Message sent successfully - will be updated when WebSocket confirms
} catch (error: Exception) {
// Remove the temporary message from display
removeMessage(-1)
pendingMessages.remove(tempId)
timeoutJob.cancel()
}
}
/**
* Clean up pending messages
*/
fun destroy() {
pendingMessages.values.forEach { (job, _) ->
job.cancel()
}
pendingMessages.clear()
}
// Abstract methods to implement
abstract suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?)
abstract suspend fun loadMessages()
abstract suspend fun loadMoreMessages()
abstract suspend fun handleWebSocketMessage(message: WebSocketMessage)
abstract suspend fun handleEditMessage(messageId: Int, content: String)
abstract suspend fun handleDeleteMessage(messageId: Int)
// Abstract UI control methods
abstract fun showCallButton(): Boolean
abstract fun getTypingHandler(): TypingHandler
}
@@ -0,0 +1,379 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch
import kotlinx.serialization.json.decodeFromJsonElement
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.back
import ru.fromchat.core.Logger
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatScreen(
panel: ChatPanel,
currentUserId: Int?,
modifier: Modifier = Modifier
) {
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
// Observe state changes
LaunchedEffect(panel) {
panel.setOnStateChange { newState ->
Logger.d("ChatScreen", "State change callback received: messages=${newState.messages.size}, typingUsers=${newState.typingUsers.map { it.username }}")
// Force state update to trigger recomposition
panelState = newState.copy() // Ensure new instance
Logger.d("ChatScreen", "panelState updated: messages=${panelState.messages.size}, typingUsers=${panelState.typingUsers.map { it.username }}")
}
// Initial state
panelState = panel.getState()
}
// Debug: Log state changes
LaunchedEffect(panelState.messages.size) {
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
// UI state
var inputText by rememberSaveable { mutableStateOf("") }
var replyTo by rememberSaveable { mutableStateOf<Message?>(null) }
var editingMessage by rememberSaveable { mutableStateOf<Message?>(null) }
var contextMenuState by remember {
mutableStateOf(
ContextMenuState(
isOpen = false,
message = null,
position = IntOffset(0, 0)
)
)
}
// Collect WebSocket messages
LaunchedEffect(Unit) {
WebSocketManager.messages.collect { message ->
Logger.d("ChatScreen", "Received WebSocket message: type=${message.type}, data=${message.data != null}")
when (message.type) {
"updates" -> {
// Handle batched updates
Logger.d("ChatScreen", "Processing updates message")
val data = message.data
if (data == null) {
Logger.w("ChatScreen", "Updates message has no data, skipping")
return@collect
}
Logger.d("ChatScreen", "Updates message has data, parsing...")
val json = ApiClient.json
try {
Logger.d("ChatScreen", "Parsing updates message")
val updatesMessage = json.decodeFromJsonElement<WebSocketUpdatesData>(data)
Logger.d("ChatScreen", "Updates message parsed: ${updatesMessage.updates.size} updates")
// Process each update in the batch
updatesMessage.updates.forEach { update ->
Logger.d("ChatScreen", "Processing update: type=${update.type}, data=${update.data != null}")
val wsMessage = WebSocketMessage(
type = update.type,
data = update.data
)
when (update.type) {
"newMessage", "messageEdited", "messageDeleted", "typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
scope.launch {
try {
panel.handleWebSocketMessage(wsMessage)
} catch (e: Exception) {
Logger.e("ChatScreen", "Error handling WebSocket message: ${e.message}", e)
}
}
}
}
}
} catch (e: Exception) {
Logger.e("ChatScreen", "Error parsing updates message: ${e.message}", e)
e.printStackTrace()
}
}
"newMessage", "messageEdited", "messageDeleted" -> {
scope.launch {
panel.handleWebSocketMessage(message)
}
}
else -> {
Logger.d("ChatScreen", "Unhandled top-level WebSocket message type: ${message.type}")
}
}
}
}
// Scroll to bottom when new messages arrive
LaunchedEffect(panelState.messages.size) {
if (panelState.messages.isNotEmpty()) {
scope.launch {
listState.animateScrollToItem(panelState.messages.size - 1)
}
}
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = panelState.title,
style = MaterialTheme.typography.titleLarge
)
AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "typing_status"
) { hasTyping ->
if (hasTyping) {
TypingIndicator(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else {
// Empty space to maintain height
Box(modifier = Modifier.height(0.dp))
}
}
}
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
actions = {
if (panel.showCallButton()) {
IconButton(onClick = { /* TODO: Handle call */ }) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Call"
)
}
}
},
scrollBehavior = scrollBehavior,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent
)
)
},
bottomBar = {
Column( // New Column to hold ChatInput below the LazyColumn
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxWidth()
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
) {
progressive = HazeProgressive.verticalGradient(
startIntensity = 0f,
endIntensity = 1f
)
}
) {
ChatInput(
text = inputText,
onTextChange = { inputText = it },
onSend = { text ->
if (editingMessage != null) {
scope.launch {
panel.handleEditMessage(editingMessage!!.id, text)
editingMessage = null
}
} else {
scope.launch {
panel.sendMessageWithImmediateDisplay(text, replyTo?.id)
replyTo = null
}
}
inputText = ""
},
typingHandler = panel.getTypingHandler(),
replyTo = replyTo,
editingMessage = editingMessage,
onClearReply = { replyTo = null },
onClearEdit = { editingMessage = null },
hazeState = hazeState
)
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures {
// Close context menu on outside tap
if (contextMenuState.isOpen) {
contextMenuState = contextMenuState.copy(isOpen = false)
}
}
}
) {
if (panelState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
items(
items = panelState.messages,
key = { it.id }
) { message ->
val isAuthor = message.user_id == currentUserId
var messagePosition by remember { mutableStateOf(IntOffset(0, 0)) }
var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) }
Box(
modifier = Modifier
.hazeSource(hazeState)
.onGloballyPositioned { coordinates ->
messagePosition = IntOffset(
coordinates.positionInRoot().x.toInt(),
coordinates.positionInRoot().y.toInt()
)
}
) {
MessageItem(
message = message,
isAuthor = isAuthor,
onLongPress = {
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = IntOffset(
messagePosition.x + tapOffset.x.toInt(),
messagePosition.y + tapOffset.y.toInt()
)
)
},
onTapPosition = { offset ->
tapOffset = offset
}
)
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
}
}
// Context menu
MessageContextMenu(
state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message ->
replyTo = message
editingMessage = null
},
onEdit = { message ->
editingMessage = message
inputText = message.content
replyTo = null
},
onDelete = { message ->
scope.launch {
panel.handleDeleteMessage(message.id)
}
},
)
}
}
}
@@ -0,0 +1,229 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import ru.fromchat.api.Message
data class ContextMenuState(
val isOpen: Boolean = false,
val message: Message? = null,
val position: IntOffset = IntOffset(0, 0)
)
@Suppress("AssignedValueIsNeverRead")
@Composable
fun MessageContextMenu(
state: ContextMenuState,
isAuthor: Boolean,
onDismiss: () -> Unit,
onReply: (Message) -> Unit,
onEdit: (Message) -> Unit,
onDelete: (Message) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowPopup by remember(state.message) {
mutableStateOf(state.isOpen && state.message != null)
}
val animationProgress = remember { mutableFloatStateOf(0f) }
LaunchedEffect(state.isOpen) {
if (state.isOpen) {
// Enter animation
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(200)
) { value, _ ->
animationProgress.floatValue = value
}
} else {
// Exit animation
animate(
initialValue = 1f,
targetValue = 0f,
animationSpec = tween(150)
) { value, _ ->
animationProgress.floatValue = value
}
kotlinx.coroutines.delay(150)
shouldShowPopup = false
}
}
// Show popup when opening
LaunchedEffect(state.isOpen, state.message) {
if (state.isOpen && state.message != null) {
shouldShowPopup = true
animationProgress.floatValue = 0f
}
}
if (shouldShowPopup && state.message != null) {
var popupSize by remember { mutableStateOf(Offset(0f, 0f)) }
var popupPosition by remember { mutableStateOf(Offset(0f, 0f)) }
// Calculate transform origin based on click position relative to popup
val transformOriginX = if (popupSize.x > 0f) {
val clickXInPopup = state.position.x - popupPosition.x.toInt()
(clickXInPopup / popupSize.x).coerceIn(0f, 1f)
} else 0f
val transformOriginY = if (popupSize.y > 0f) {
val clickYInPopup = state.position.y - popupPosition.y.toInt()
(clickYInPopup / popupSize.y).coerceIn(0f, 1f)
} else 0f
val scale = animationProgress.floatValue * 0.2f + 0.8f
val alpha = animationProgress.floatValue
Popup(
onDismissRequest = onDismiss,
alignment = Alignment.TopStart,
offset = state.position,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
Surface(
modifier = modifier
.width(160.dp)
.onGloballyPositioned { coordinates ->
popupSize = Offset(
coordinates.size.width.toFloat(),
coordinates.size.height.toFloat()
)
popupPosition = coordinates.positionInRoot()
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
alpha = alpha,
transformOrigin = TransformOrigin(transformOriginX, transformOriginY)
)
.shadow(8.dp, RoundedCornerShape(8.dp)),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHighest
) {
Column {
// Reply button (always shown)
ContextMenuItem(
icon = Icons.AutoMirrored.Filled.Reply,
text = "Reply",
onClick = {
onReply(state.message)
onDismiss()
}
)
// Edit button (only for own messages)
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Edit,
text = "Edit",
onClick = {
onEdit(state.message)
onDismiss()
}
)
}
// Delete button (only for own messages)
if (isAuthor) {
ContextMenuItem(
icon = Icons.Default.Delete,
text = "Delete",
onClick = {
onDelete(state.message)
onDismiss()
},
isError = true
)
}
}
}
}
}
}
@Composable
private fun ContextMenuItem(
icon: ImageVector,
text: String,
onClick: () -> Unit,
isError: Boolean = false,
modifier: Modifier = Modifier
) {
val textColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
val iconColor = if (isError) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
Box(
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = text,
tint = iconColor,
modifier = Modifier.size(20.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = textColor
)
}
}
}
@@ -0,0 +1,262 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@OptIn(ExperimentalTime::class)
@Composable
fun MessageItem(
message: Message,
isAuthor: Boolean,
onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {},
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
initialOffsetY = { 20 },
animationSpec = tween(300)
),
exit = fadeOut(animationSpec = tween(200)) + slideOutVertically(
targetOffsetY = { -10 },
animationSpec = tween(200)
),
modifier = modifier
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = { offset ->
onTapPosition(offset)
onLongPress()
}
)
},
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (!isAuthor) {
// Avatar at bottom
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
size = 32.dp
)
Spacer(modifier = Modifier.width(8.dp))
}
Column(
modifier = Modifier
.weight(1f, fill = false)
.widthIn(max = 280.dp),
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
) {
// Reply preview
message.reply_to?.let { replyTo ->
ReplyPreview(
replyTo = replyTo,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Message bubble
val isDark = isSystemInDarkTheme()
Box(
modifier = Modifier
.clip(
RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = if (isAuthor) 20.dp else 8.dp,
bottomEnd = if (isAuthor) 8.dp else 20.dp
)
)
.then(
if (isAuthor) {
Modifier.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = 20.dp,
bottomEnd = 8.dp
),
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
)
} else {
Modifier
}
)
.background(
brush = if (isAuthor) {
getMessageGradient(isDark)
} else {
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest,
MaterialTheme.colorScheme.surfaceContainerHighest
)
)
}
)
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column {
// Username inside bubble (for received messages)
if (!isAuthor) {
Text(
text = message.username,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Message content
Text(
text = message.content,
style = MaterialTheme.typography.bodyMedium,
color = if (isAuthor) {
Color.White
} else {
MaterialTheme.colorScheme.onSurface
}
)
// Timestamp and edited indicator
Row(
modifier = Modifier.padding(top = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatTime(message.timestamp),
style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp,
color = if (isAuthor) {
Color.White.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
}
)
if (message.is_edited) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "(edited)",
style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp,
color = if (isAuthor) {
Color.White.copy(alpha = 0.7f)
} else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
}
)
}
}
}
}
}
}
}
}
@Composable
private fun ReplyPreview(
replyTo: Message,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Column {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
@ExperimentalTime
private fun formatTime(timestamp: String): String {
return try {
val instant = Instant.parse(timestamp)
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
val hour = localDateTime.hour.toString().padStart(2, '0')
val minute = localDateTime.minute.toString().padStart(2, '0')
"$hour:$minute"
} catch (e: Exception) {
// Fallback: try parsing without timezone if it fails
try {
val parts = timestamp.split("T")
if (parts.size == 2) {
val timePart = parts[1].split(".")[0]
if (timePart.length >= 5) {
timePart.take(5) // Return HH:mm
} else {
""
}
} else {
""
}
} catch (e2: Exception) {
""
}
}
}
@@ -0,0 +1,181 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.MessageDeletedData
import ru.fromchat.api.ReactionUpdateData
import ru.fromchat.api.TypingUpdateData
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.core.Logger
class PublicChatPanel(
chatName: String,
currentUserId: Int?,
scope: CoroutineScope
) : ChatPanel(
id = "public-$chatName",
currentUserId = currentUserId,
scope = scope
) {
private val typingHandler = PublicChatTypingHandler(scope)
private var messagesLoaded = false
init {
updateState { it.copy(title = chatName) }
// Observe typing users from the handler and update panel state
scope.launch {
typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) }
}
}
}
private fun handleReactionUpdate(reactionUpdate: ReactionUpdateData) {
updateMessage(reactionUpdate.message_id) { message ->
message.copy(reactions = reactionUpdate.reactions)
}
}
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
ApiClient.sendMessage(content, replyToId, clientMessageId)
}
override suspend fun loadMessages() {
if (messagesLoaded) return
setLoading(true)
try {
val response = ApiClient.getMessages(limit = 50)
if (response.messages.isNotEmpty()) {
clearMessages()
response.messages.forEach { message ->
addMessage(message)
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true
} catch (e: Exception) {
// Handle error
} finally {
setLoading(false)
}
}
override suspend fun loadMoreMessages() {
if (!_state.hasMoreMessages || _state.isLoadingMore) return
val messages = _state.messages
if (messages.isEmpty()) return
val oldestMessage = messages.first()
setLoadingMore(true)
try {
val response = ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
if (response.messages.isNotEmpty()) {
// Prepend older messages (they come in reverse chronological order)
updateState { currentState ->
currentState.copy(
messages = response.messages.reversed() + currentState.messages
)
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (e: Exception) {
// Handle error
} finally {
setLoadingMore(false)
}
}
private fun handleSingleUpdate(updateMessage: WebSocketMessage) {
val json = ApiClient.json
when (updateMessage.type) {
"newMessage" -> {
val data = updateMessage.data ?: return
val newMsg = json.decodeFromJsonElement(Message.serializer(), data)
Logger.d("PublicChatPanel", "New message received: id=${newMsg.id}, content=${newMsg.content.take(50)}")
if (newMsg.client_message_id != null && newMsg.user_id == currentUserId) {
handleMessageConfirmed(newMsg.client_message_id, newMsg)
} else {
addMessage(newMsg)
}
}
"messageEdited" -> {
val data = updateMessage.data ?: return
val editedMsg = json.decodeFromJsonElement(Message.serializer(), data)
updateMessage(editedMsg.id) { editedMsg }
}
"messageDeleted" -> {
val data = updateMessage.data ?: return
val deletedData = json.decodeFromJsonElement(MessageDeletedData.serializer(), data)
removeMessage(deletedData.message_id)
}
"reactionUpdate" -> {
val data = updateMessage.data ?: return
val reactionUpdate = json.decodeFromJsonElement(ReactionUpdateData.serializer(), data)
handleReactionUpdate(reactionUpdate)
}
"typing" -> {
val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
Logger.d("PublicChatPanel", "Received typing event for user: ${typingData.username}")
typingHandler.handleTypingEvent(typingData.userId, typingData.username)
}
"stopTyping" -> {
val data = updateMessage.data ?: return
val typingData = json.decodeFromJsonElement(TypingUpdateData.serializer(), data)
Logger.d("PublicChatPanel", "Received stopTyping event for user: ${typingData.username}")
typingHandler.handleStopTypingEvent(typingData.userId)
}
"statusUpdate" -> {
// Handled in ChatScreen or by global WebSocketManager listeners
}
"suspended" -> {
// Handled by global WebSocketManager listeners or shown as a toast
}
"account_deleted" -> {
// Handled by global WebSocketManager listeners or shown as a toast
}
else -> {
Logger.w("PublicChatPanel", "Unhandled WebSocket update type: ${updateMessage.type}")
}
}
}
override suspend fun handleWebSocketMessage(message: WebSocketMessage) {
Logger.d("PublicChatPanel", "Handling raw WebSocket message: type=${message.type}")
if (message.type == "updates") {
val json = ApiClient.json
val data = message.data ?: return
val updatesData = json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
Logger.d("PublicChatPanel", "Received ${updatesData.updates.size} batched updates (seq: ${updatesData.seq})")
updatesData.updates.forEach { update ->
handleSingleUpdate(update)
}
} else {
// Fallback for non-batched messages (legacy or direct signals)
handleSingleUpdate(message)
}
}
override suspend fun handleEditMessage(messageId: Int, content: String) {
ApiClient.editMessage(messageId, content)
}
override suspend fun handleDeleteMessage(messageId: Int) {
// Remove immediately from UI
deleteMessageImmediately(messageId)
// Send delete request
ApiClient.deleteMessage(messageId)
}
override fun showCallButton(): Boolean = false
override fun getTypingHandler(): TypingHandler = typingHandler
}
@@ -0,0 +1,37 @@
package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
@Composable
fun PublicChatScreen() {
val scope = rememberCoroutineScope()
val currentUserId = ApiClient.user?.id
// Create panel instance
val panel = remember {
PublicChatPanel(
chatName = "General Chat",
currentUserId = currentUserId,
scope = scope
)
}
// Load messages on first appear
LaunchedEffect(Unit) {
scope.launch {
panel.loadMessages()
}
}
// Render with ChatScreen
ChatScreen(
panel = panel,
currentUserId = currentUserId
)
}
@@ -0,0 +1,89 @@
package ru.fromchat.ui.chat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import ru.fromchat.api.ApiClient
import kotlin.time.Duration.Companion.seconds
/**
* Interface for handling typing indicators
*/
interface TypingHandler {
fun sendTyping()
fun stopTyping()
fun handleTypingEvent(userId: Int, username: String)
fun handleStopTypingEvent(userId: Int)
val typingUsers: StateFlow<List<TypingUser>>
}
@Serializable
data class TypingUser(
val userId: Int,
val username: String
)
/**
* Typing handler for public chat using WebSocket
*/
class PublicChatTypingHandler(
private val scope: CoroutineScope
) : TypingHandler {
private var stopTypingJob: Job? = null
private val _typingUsers = MutableStateFlow<List<TypingUser>>(emptyList())
override val typingUsers = _typingUsers.asStateFlow()
override fun sendTyping() {
scope.launch {
try {
ApiClient.sendTyping()
} catch (e: Exception) {
// Ignore errors
}
}
// Cancel existing stop typing job
stopTypingJob?.cancel()
// Schedule stop typing after delay
stopTypingJob = scope.launch {
delay(3.seconds) // 3 seconds
stopTyping()
}
}
override fun stopTyping() {
stopTypingJob?.cancel()
stopTypingJob = null
scope.launch {
try {
ApiClient.sendStopTyping()
} catch (e: Exception) {
// Ignore errors
}
}
}
override fun handleTypingEvent(userId: Int, username: String) {
_typingUsers.update { currentUsers ->
if (currentUsers.none { it.userId == userId }) {
currentUsers + TypingUser(userId, username)
} else {
currentUsers
}
}
}
override fun handleStopTypingEvent(userId: Int) {
_typingUsers.update { currentUsers ->
currentUsers.filter { it.userId != userId }
}
}
}
@@ -0,0 +1,129 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.typing_many
import ru.fromchat.typing_single
import ru.fromchat.typing_two
@Composable
fun TypingIndicator(
typingUsers: List<String>,
modifier: Modifier = Modifier
) {
if (typingUsers.isEmpty()) return
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
TypingDots()
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatTypingText(typingUsers),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
}
}
@Composable
private fun formatTypingText(typingUsers: List<String>): String {
return when (typingUsers.size) {
0 -> ""
1 -> stringResource(Res.string.typing_single, typingUsers[0])
2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1])
else -> stringResource(
Res.string.typing_many,
typingUsers[0],
typingUsers[1],
typingUsers.size - 2
)
}
}
@Composable
private fun TypingDots() {
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
val dot1Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 0
1f at 200
0.3f at 400
}
),
label = "dot1"
)
val dot2Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 200
1f at 400
0.3f at 600
}
),
label = "dot2"
)
val dot3Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 400
1f at 600
0.3f at 800
}
),
label = "dot3"
)
Row(verticalAlignment = Alignment.CenterVertically) {
Dot(alpha = dot1Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot2Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot3Alpha)
}
}
@Composable
private fun Dot(alpha: Float) {
Surface(
modifier = Modifier
.width(4.dp)
.height(4.dp)
.alpha(alpha),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {}
}
@@ -0,0 +1,55 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chat_last_mesaage
import ru.fromchat.chats
import ru.fromchat.public_chat
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatsTab() {
val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(
text = stringResource(Res.string.chats),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
item {
ListItem(
headlineContent = { Text(stringResource(Res.string.public_chat)) },
supportingContent = { Text(stringResource(Res.string.chat_last_mesaage)) },
modifier = Modifier.clickable {
navController.navigate("chats/publicChat")
}
)
}
}
}
}
@@ -0,0 +1,91 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chats
import ru.fromchat.coming_soon
import ru.fromchat.contacts
import ru.fromchat.dms
import ru.fromchat.settings
import ru.fromchat.utils.exclude
@Suppress("AssignedValueIsNeverRead")
@Composable
fun MainScreen(onLogout: () -> Unit = {}) {
var selectedTab by rememberSaveable { mutableStateOf("chats") }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == "chats",
onClick = { selectedTab = "chats" },
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "contacts",
onClick = { selectedTab = "contacts" },
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "dms",
onClick = { selectedTab = "dms" },
label = { Text(stringResource(Res.string.dms)) },
icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "settings",
onClick = { selectedTab = "settings" },
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
}
},
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding()
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (selectedTab) {
"chats" -> ChatsTab()
"contacts" -> {
Text(stringResource(Res.string.coming_soon))
}
"dms" -> {
Text(stringResource(Res.string.coming_soon))
}
"settings" -> {
SettingsTab(onLogout = onLogout)
}
}
}
}
}
@@ -0,0 +1,248 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.materialYouAvailable
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.as_system
import ru.fromchat.change_server
import ru.fromchat.change_server_d
import ru.fromchat.core.Settings
import ru.fromchat.dark
import ru.fromchat.light
import ru.fromchat.logout
import ru.fromchat.materialYou
import ru.fromchat.materialYou_d
import ru.fromchat.settings
import ru.fromchat.theme
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.Theme
import ru.fromchat.ui.dynamicThemeEnabled
import ru.fromchat.ui.theme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsTab(
onLogout: () -> Unit
) {
TabBase {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(
text = stringResource(Res.string.settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
IconButton(onClick = { navController.navigate("about") }) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = stringResource(Res.string.about)
)
}
},
scrollBehavior = scrollBehavior
)
},
) { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
var materialYouSwitch by remember {
mutableStateOf(
Settings.materialYou && materialYouAvailable
)
}
Category(Modifier.padding(top = 16.dp)) {
// Material You
SwitchListItem(
headline = stringResource(Res.string.materialYou),
supportingText = stringResource(Res.string.materialYou_d),
enabled = materialYouAvailable,
checked = materialYouSwitch,
onCheckedChange = {
materialYouSwitch = it
Settings.materialYou = it
dynamicThemeEnabled = it
},
divider = true,
dividerColor = MaterialTheme.colorScheme.surface,
dividerThickness = 2.dp,
leadingContent = {
Icon(
imageVector = Icons.Filled.Wallpaper,
contentDescription = null
)
}
)
// Theme
ListItem(
headline = stringResource(Res.string.theme),
leadingContent = {
Icon(
imageVector = Icons.Filled.Brush,
contentDescription = null
)
},
bottomContent = {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
var selectedIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
val options = listOf(
stringResource(Res.string.as_system),
stringResource(Res.string.light),
stringResource(Res.string.dark)
)
options.forEachIndexed { index, label ->
FilterChip(
onClick = {
selectedIndex = index
Settings.theme = Theme.entries[index]
theme = Theme.entries[index]
},
selected = index == selectedIndex,
leadingIcon = {
if (index == 0) {
Spacer(Modifier.width(16.dp))
}
when (index) {
0 -> Icon(Icons.Filled.Settings, null)
1 -> Icon(Icons.Filled.LightMode, null)
2 -> Icon(Icons.Filled.DarkMode, null)
}
},
label = {
Text(
text = label,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
)
}
// Server Configuration
Category(Modifier.padding(top = 16.dp)) {
ListItem(
headline = stringResource(Res.string.change_server),
supportingText = stringResource(Res.string.change_server_d),
onClick = {
// Logout and navigate to server config when server changes
scope.launch {
// Logout from current server
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors (server might be unreachable)
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
WebSocketManager.shutdown()
navController.navigate("serverConfig")
}
},
divider = true,
dividerColor = MaterialTheme.colorScheme.surface,
dividerThickness = 2.dp,
leadingContent = {
Icon(Icons.Filled.Storage, null)
}
)
ListItem(
headline = stringResource(Res.string.logout),
leadingContent = {
Icon(Icons.AutoMirrored.Filled.Logout, null)
},
onClick = {
scope.launch {
// Logout
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
// Shutdown WebSocket
WebSocketManager.shutdown()
// Navigate back to auth
onLogout()
}
}
)
}
}
}
}
}
@@ -0,0 +1,37 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import com.pr0gramm3r101.utils.resetFocus
@Composable
inline fun TabBase(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
resetFocus(keyboardController, focusManager)
}
.then(modifier)
) {
content()
}
}
@@ -0,0 +1,180 @@
package ru.fromchat.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.storage.ServerConfigData
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.back
import ru.fromchat.core.config.Config
import ru.fromchat.https_enabled
import ru.fromchat.save_continue
import ru.fromchat.server_config_subtitle
import ru.fromchat.server_config_title
import ru.fromchat.server_url_hint
import ru.fromchat.server_url_label
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerConfigScreen() {
val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available
var serverUrl by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val config = Config.serverConfig.value
if (config != null) {
serverUrl = config.serverUrl
httpsEnabled = config.httpsEnabled
}
}
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = navController::navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.windowInsetsPadding(WindowInsets.ime)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(Res.string.server_config_title),
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.server_config_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text(stringResource(Res.string.server_url_label)) },
placeholder = { Text(stringResource(Res.string.server_url_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = httpsEnabled,
onCheckedChange = { httpsEnabled = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(Res.string.https_enabled))
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
isLoading = true
scope.launch {
val config = ServerConfigData(serverUrl, httpsEnabled)
Config.updateServerConfig(config)
// Ensure we're logged out when server config changes
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
// Shutdown WebSocket (will reconnect on login)
WebSocketManager.shutdown()
// Navigate to login and wipe entire back stack
navController.navigateAndWipeBackStack("login")
}
},
enabled = !isLoading && serverUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(Res.string.save_continue))
}
}
}
}
@@ -0,0 +1,9 @@
package ru.fromchat.utils
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.only
@Suppress("NOTHING_TO_INLINE")
inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides))
@@ -0,0 +1,88 @@
@file:Suppress("NOTHING_TO_INLINE")
package ru.fromchat.utils
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.ResponseException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.LoggingConfig
import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.serialization.Configuration
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonBuilder
/**
* Configures the Ktor client for kotlinx.serialization JSON with custom settings.
* @param settings Lambda to configure the [JsonBuilder].
*/
@PublishedApi
internal inline fun Configuration.json(crossinline settings: JsonBuilder.() -> Unit) {
json(
Json {
settings()
}
)
}
/**
* Installs [ContentNegotiation] with JSON support and custom settings in a Ktor client config.
* @param settings Lambda to configure the [JsonBuilder].
*/
inline fun HttpClientConfig<*>.jsonConfig(
crossinline settings: JsonBuilder.() -> Unit
) = install(ContentNegotiation) {
json {
settings()
}
}
/**
* Installs [DefaultRequest] in a Ktor client config.
* @param settings Lambda to configure the [DefaultRequest.DefaultRequestBuilder].
*/
inline fun HttpClientConfig<*>.defaultRequest(
crossinline settings: DefaultRequest.DefaultRequestBuilder.() -> Unit
) = install(DefaultRequest) {
settings()
}
/**
* Installs [Logging] in a Ktor client config.
* @param settings Lambda to configure the [LoggingConfig].
*/
inline fun HttpClientConfig<*>.logging(
crossinline settings: LoggingConfig.() -> Unit
) = install(Logging) {
settings()
}
/**
* Installs [Logging] in a Ktor client config with default settings (all logs).
*/
inline fun HttpClientConfig<*>.logging() = logging {
logger = Logger.SIMPLE
level = LogLevel.ALL
}
/**
* Throws if the HTTP response status code is an error (>=400).
* @return The [HttpResponse] if successful.
* @throws ClientRequestException on 4xx error code.
* @throws ServerResponseException on 5xx error code.
* @throws ResponseException on unknown error code.
*/
suspend fun HttpResponse.failOnError() =
when (status.value) {
in 100..399 -> this
in 400..499 -> throw ClientRequestException(this, bodyAsText())
in 500..599 -> throw ServerResponseException(this, bodyAsText())
else -> throw ResponseException(this, bodyAsText())
}
@@ -0,0 +1,7 @@
package ru.fromchat
import ru.fromchat.ui.App
import androidx.compose.ui.window.ComposeUIViewController
fun MainViewController() = ComposeUIViewController { App() }
@@ -0,0 +1,13 @@
package ru.fromchat.api
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.Darwin
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
actual fun createPlatformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient {
@Suppress("UNCHECKED_CAST")
return HttpClient(Darwin, block as HttpClientConfig<DarwinClientEngineConfig>.() -> Unit)
}
@@ -0,0 +1,22 @@
package ru.fromchat.core
import platform.Foundation.NSLog
actual object Logger {
actual fun d(tag: String, message: String, throwable: Throwable?) {
NSLog("DEBUG: [%s] %s %s", tag, message, throwable?.message ?: "")
}
actual fun i(tag: String, message: String, throwable: Throwable?) {
NSLog("INFO: [%s] %s %s", tag, message, throwable?.message ?: "")
}
actual fun w(tag: String, message: String, throwable: Throwable?) {
NSLog("WARN: [%s] %s %s", tag, message, throwable?.message ?: "")
}
actual fun e(tag: String, message: String, throwable: Throwable?) {
NSLog("ERROR: [%s] %s %s", tag, message, throwable?.message ?: "")
}
}
@@ -0,0 +1,10 @@
package ru.fromchat.ui
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
@Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (darkTheme) darkColorScheme()
else lightColorScheme()