Set up multiplatform structure

This commit is contained in:
2025-12-05 23:03:29 +03:00
Unverified
parent 84e07e338d
commit 7696d26fd3
111 changed files with 4586 additions and 280 deletions
+1
View File
@@ -0,0 +1 @@
/build
+146
View File
@@ -0,0 +1,146 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
kotlin("plugin.serialization") version "1.9.22"
}
kotlin {
androidTarget()
compilerOptions {
freeCompilerArgs.addAll("-Xexpect-actual-classes")
}
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.adaptive.android)
implementation(libs.ktor.client.okhttp)
implementation(libs.slf4j.android)
}
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)
// Serialization
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.io.core)
// Ktor
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)
implementation(project(":utils"))
}
iosMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.darwin)
}
}
}
android {
namespace = "ru.fromchat"
compileSdk = 36
defaultConfig {
applicationId = "ru.fromchat"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
}
signingConfigs {
create("release") {
val keystoreProperties = Properties().apply {
load(FileInputStream(rootProject.file("keys/keystore.properties")))
}
storeFile = rootProject.file("keys/release.jks")
keyAlias = "release"
storePassword = keystoreProperties["storePassword"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
enableV3Signing = true
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
dependencies {
debugImplementation(compose.uiTooling)
}
compose.resources {
publicResClass = false
packageOfResClass = "ru.fromchat"
generateResClass = auto
}
tasks.register("generateResourceAccessors") {
dependsOn(
*(
tasks.filter {
it.name.startsWith("generateResourceAccessors") &&
!it.name.matches("^(:${project.name})?generateResourceAccessors$".toRegex())
}.toTypedArray()
)
)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.FromChat"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.FromChat"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,17 @@
package ru.fromchat
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import ru.fromchat.ui.App
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
App()
}
}
}
@@ -0,0 +1,16 @@
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,13 @@
package ru.fromchat.api
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.engine.okhttp.OkHttpConfig
actual fun createPlatformHttpClient(
block: HttpClientConfig<*>.() -> Unit
): HttpClient {
@Suppress("UNCHECKED_CAST")
return HttpClient(OkHttp, block as HttpClientConfig<OkHttpConfig>.() -> Unit)
}
@@ -0,0 +1,21 @@
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,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,23 @@
<resources>
<string name="app_name">FromChat</string>
<string name="register">Регистрация</string>
<string name="register_d">Создайте новый аккаунт</string>
<string name="register_button">Зарегистрироваться</string>
<string name="username">Имя пользователя</string>
<string name="password">Пароль</string>
<string name="confirm_password">Подтвердите пароль</string>
<string name="login">Войти</string>
<string name="login_d">Войдите в аккаунт</string>
<string name="welcome">Добро пожаловать!</string>
<string name="fill_all_fields">Пожалуйста, заполните все поля</string>
<string name="username_length_error">Имя пользователя должно быть от 3 до 20 символов</string>
<string name="password_length_error">Пароль должен быть от 5 до 50 символов</string>
<string name="passwords_dont_match">Пароли не совпадают</string>
<string name="chats">Чаты</string>
<string name="contacts">Контакты</string>
<string name="dms">ЛС</string>
<string name="coming_soon">Скоро будет...</string>
<string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: Последние сообщение</string>
<string name="message_placeholder">Введите сообщение</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.FromChat" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,21 @@
package ru.fromchat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
const val API_HOST = "https://fromchat.ru"
const val WS_API_HOST = "wss://fromchat.ru"
val DATETIME_FORMAT = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
@@ -0,0 +1,117 @@
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.request.bearerAuth
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
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 ru.fromchat.API_HOST
import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile
/**
* 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)
}
@Volatile
var token: String? = null
private set
@Volatile
var user: User? = null
private set
suspend fun login(request: LoginRequest) =
http
.post("$API_HOST/api/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("$API_HOST/api/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
.failOnError()
suspend fun getMessages() =
http
.get("$API_HOST/api/get_messages") {
contentType(ContentType.Application.Json)
}
.failOnError()
.body<MessagesResponse>()
suspend fun send(message: String) =
http
.post("$API_HOST/api/send_message") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(SendMessageRequest(message))
}
.failOnError()
.body<SendMessageResponse>()
suspend fun editMessage(messageId: Int, content: String) =
http
.put("$API_HOST/api/edit_message/$messageId") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
setBody(EditMessageRequest(content))
}
.failOnError()
.body<SendMessageResponse>()
suspend fun deleteMessage(messageId: Int) =
http
.delete("$API_HOST/api/delete_message/$messageId") {
contentType(ContentType.Application.Json)
bearerAuth(token!!)
}
.failOnError()
}
@@ -0,0 +1,101 @@
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 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
)
@@ -0,0 +1,32 @@
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(
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 {
"Unexpected error"
}
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("Unexpected error", e)
return Result.failure(e)
}
}
@@ -0,0 +1,146 @@
package ru.fromchat.api
import ru.fromchat.core.Logger
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.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 ru.fromchat.WS_API_HOST
import kotlin.coroutines.suspendCoroutine
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: Boolean = false
@Volatile private var session: DefaultClientWebSocketSession? = null
fun connect() {
Logger.d("WebSocketManager", "Connecting to WebSocket")
if (connecting) return
connecting = true
scope.launch {
while (isActive) {
try {
ApiClient.http.webSocket(
method = HttpMethod.Get,
request = {
url("$WS_API_HOST/api/chat/ws")
}
) {
session = this
connecting = false
Logger.d("WebSocketManager", "WebSocket connected")
for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue
Logger.d("WebSocketManager", "Received payload: $text")
try {
val msg = 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: ${e.message}", e)
connecting = false
session = null
delay(3000)
} finally {
Logger.w("WebSocketManager", "WebSocket disconnected")
session = null
connecting = false
}
}
}
}
suspend fun send(message: WebSocketMessage) {
val session = session
if (session != null) {
try {
session.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() {
scope.cancel()
}
}
@@ -0,0 +1,8 @@
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,82 @@
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.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import ru.fromchat.api.WebSocketManager
import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.main.MainScreen
val LocalNavController = compositionLocalOf<NavController> { error("") }
@Composable
fun App() {
LaunchedEffect(Unit) {
WebSocketManager.connect()
}
FromChatTheme(dynamicColor = false) {
val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400)
CompositionLocalProvider(
LocalNavController provides navController
) {
NavHost(
navController = navController,
startDestination = "login",
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("login") {
LoginScreen(
onLoginSuccess = { navController.navigate("chat") },
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") { MainScreen() }
composable("chats/publicChat") { /* PublicChatScreen() */ }
}
}
}
}
@@ -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,98 @@
package ru.fromchat.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFFDBB9F9),
onPrimary = Color(0xFF3E2458),
primaryContainer = Color(0xFF563B71),
onPrimaryContainer = Color(0xFFF0DBFF),
secondary = Color(0xFFD0C1DA),
onSecondary = Color(0xFF362C3F),
secondaryContainer = Color(0xFF4D4356),
onSecondaryContainer = Color(0xFFEDDDF6),
tertiary = Color(0xFFF3B7BE),
onTertiary = Color(0xFF4B252B),
tertiaryContainer = Color(0xFF653A40),
onTertiaryContainer = Color(0xFFFFD9DD),
error = Color(0xFFFFB4AB),
onError = Color(0xFF690005),
errorContainer = Color(0xFF93000A),
onErrorContainer = Color(0xFFFFDAD6),
background = Color(0xFF151218),
onBackground = Color(0xFFE8E0E8),
surface = Color(0xFF151218),
onSurface = Color(0xFFE8E0E8),
surfaceVariant = Color(0xFF4A454E),
onSurfaceVariant = Color(0xFFCCC4CE),
outline = Color(0xFF968E98),
outlineVariant = Color(0xFF4A454E),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFFE8E0E8),
inverseOnSurface = Color(0xFF332F35),
inversePrimary = Color(0xFF6F528A),
surfaceDim = Color(0xFF151218),
surfaceBright = Color(0xFF3C383E),
surfaceContainerLowest = Color(0xFF100D12),
surfaceContainerLow = Color(0xFF1E1A20),
surfaceContainer = Color(0xFF221E24),
surfaceContainerHigh = Color(0xFF2C292E),
surfaceContainerHighest = Color(0xFF373339),
)
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF1F6586),
onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFC5E7FF),
onPrimaryContainer = Color(0xFF004C6A),
secondary = Color(0xFF4E616D),
onSecondary = Color(0xFFFFFFFF),
secondaryContainer = Color(0xFFD2E5F4),
onSecondaryContainer = Color(0xFF374955),
tertiary = Color(0xFF615A7C),
onTertiary = Color(0xFFFFFFFF),
tertiaryContainer = Color(0xFFE7DEFF),
onTertiaryContainer = Color(0xFF494263),
error = Color(0xFFBA1A1A),
onError = Color(0xFFFFFFFF),
errorContainer = Color(0xFFFFDAD6),
onErrorContainer = Color(0xFF93000A),
background = Color(0xFFF6FAFE),
onBackground = Color(0xFF181C1F),
surface = Color(0xFFF6FAFE),
onSurface = Color(0xFF181C1F),
surfaceVariant = Color(0xFFDDE3EA),
onSurfaceVariant = Color(0xFF41484D),
outline = Color(0xFF71787E),
outlineVariant = Color(0xFFC1C7CE),
scrim = Color(0xFF000000),
inverseSurface = Color(0xFF2C3134),
inverseOnSurface = Color(0xFFEDF1F5),
inversePrimary = Color(0xFF91CEF4),
surfaceDim = Color(0xFFD7DADF),
surfaceBright = Color(0xFFF6FAFE),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF0F4F8),
surfaceContainer = Color(0xFFEBEEF3),
surfaceContainerHigh = Color(0xFFE5E8ED),
surfaceContainerHighest = Color(0xFFDFE3E7),
)
@Composable
fun FromChatTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false, // Disabled for multiplatform compatibility
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}
@@ -0,0 +1,124 @@
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.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.ui.RowHeader
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
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(R.string.welcome),
subtitle = stringResource(R.string.login_d)
)
if (alert != null) {
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
}
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.username)) },
singleLine = true
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
val alert_error_filling = stringResource(R.string.fill_all_fields)
Button(
onClick = {
if (username.isBlank() || password.isBlank()) {
alert = alert_error_filling
return@Button
}
// Derive auth secret before sending (matches frontend implementation)
scope.launch {
val derived = deriveAuthSecret(username.trim(), password.trim())
apiRequest(
onError = { message, _ ->
alert = message
},
onSuccess = { onLoginSuccess() }
) {
ApiClient.login(LoginRequest(username.trim(), derived))
}
}
}
) {
Text(stringResource(R.string.login))
}
Button(onClick = onNavigateToRegister) {
Text(stringResource(R.string.register_button))
}
}
}
}
}
}
@@ -0,0 +1,177 @@
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.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.fromchat.R
import ru.fromchat.api.ApiClient
import ru.fromchat.api.RegisterRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
@Composable
fun RegisterScreen(
onRegistered: () -> Unit
) {
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(R.string.register),
subtitle = stringResource(R.string.register_d)
)
if (alert != null) {
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
}
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.username)) },
singleLine = true
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display Name") },
singleLine = true
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(R.string.confirm_password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
val alert_error_filling = stringResource(R.string.fill_all_fields)
val alert_error_password_little = stringResource(R.string.password_length_error)
val alert_error_name_little = stringResource(R.string.username_length_error)
val alert_error_password_confrim = stringResource(R.string.passwords_dont_match)
IconButton(
onClick = { navController.navigateUp() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
Button(
onClick = {
// Checks
if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
alert = alert_error_filling
return@Button
}
if (password != confirmPassword) {
alert = alert_error_password_confrim
return@Button
}
if (username.length !in 3..20) {
alert = alert_error_name_little
return@Button
}
if (displayName.isBlank() || displayName.length > 64) {
alert = "Display name must be between 1 and 64 characters"
return@Button
}
if (password.length !in 5..50) {
alert = alert_error_password_little
return@Button
}
// Derive auth secret before sending (matches frontend implementation)
scope.launch {
val derived = deriveAuthSecret(username.trim(), password)
apiRequest(
onError = { message, _ ->
alert = message
},
onSuccess = { onRegistered() }
) {
ApiClient.register(
RegisterRequest(
username.trim(),
displayName.trim(),
derived,
derived
)
)
}
}
}
) {
Text(stringResource(R.string.register_button))
}
}
}
}
}
}
@@ -0,0 +1,52 @@
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import ru.fromchat.R
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(R.string.chats),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
item {
ListItem(
headlineContent = { Text(stringResource(R.string.public_chat)) },
supportingContent = { Text(stringResource(R.string.chat_last_mesaage)) },
modifier = Modifier.clickable {
navController.navigate("chats/publicChat")
}
)
}
}
}
}
@@ -0,0 +1,75 @@
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.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.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import ru.fromchat.R
import ru.fromchat.utils.exclude
@Composable
fun MainScreen() {
var selectedTab by remember { mutableStateOf("chats") }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == "chats",
onClick = { selectedTab = "chats" },
label = { Text(stringResource(R.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "contacts",
onClick = { selectedTab = "contacts" },
label = { Text(stringResource(R.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "dms",
onClick = { selectedTab = "dms" },
label = { Text(stringResource(R.string.dms)) },
icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
)
}
},
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
modifier = Modifier.imePadding()
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (selectedTab) {
"chats" -> ChatsTab()
"contacts" -> {
Text(stringResource(R.string.coming_soon))
}
"dms" -> {
Text(stringResource(R.string.coming_soon))
}
}
}
}
}
@@ -0,0 +1,8 @@
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
inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides))
@@ -0,0 +1,87 @@
@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.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 [Logging.Config].
*/
inline fun HttpClientConfig<*>.logging(
crossinline settings: Logging.Config.() -> 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,6 @@
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,21 @@
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,17 @@
package ru.fromchat
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}