mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Refactor code to support AGP 9.0
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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 & 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()
|
||||
Reference in New Issue
Block a user