mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Set up multiplatform structure
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user