Make the base

This commit is contained in:
2025-09-07 20:06:30 +03:00
Unverified
parent a28b5fa5b8
commit 1c14053e96
9 changed files with 649 additions and 2 deletions
+2 -1
View File
@@ -10,7 +10,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FromChat"> android:theme="@style/Theme.FromChat"
android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
+72
View File
@@ -0,0 +1,72 @@
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.websocket.WebSockets
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.URLBuilder
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import ru.fromchat.API_HOST
object ApiClient {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
encodeDefaults = true
}
val http = HttpClient {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
level = LogLevel.INFO
logger = object : Logger {
override fun log(message: String) {
// Replace with Logcat if needed
println(message)
}
}
}
install(WebSockets)
}
@Volatile
var token: String? = null
private set
private fun apiUrlBuilder(): URLBuilder {
return URLBuilder().apply {
protocol = io.ktor.http.URLProtocol.HTTPS
host = API_HOST
}
}
suspend fun login(request: LoginRequest): LoginResponse {
// Endpoint exists in web: POST /login with {username,password}
val response = http.post("https://fromchat.ru/api/login") {
contentType(ContentType.Application.Json)
setBody(request)
}.body<LoginResponse>()
token = response.token
return response
}
suspend fun register(request: RegisterRequest) {
// Endpoint exists in web: POST /register with {username,password,confirm_password}
http.post("https://fromchat.ru/api/register") {
contentType(ContentType.Application.Json)
setBody(request)
}
}
}
@@ -0,0 +1,60 @@
package ru.fromchat.api
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class RegisterRequest(
val username: String,
val password: String,
val confirm_password: String
)
@Serializable
data class ErrorResponse(
val message: String? = null
)
@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
)
// 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: kotlinx.serialization.json.JsonElement? = null,
val error: WebSocketError? = null
)
@@ -0,0 +1,123 @@
package ru.fromchat.api
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.HttpMethod
import io.ktor.http.URLProtocol
import io.ktor.http.encodedPath
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.channels.Channel
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.API_HOST
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()
@Volatile
private var globalHandler: ((WebSocketMessage) -> Unit)? = null
fun setGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)?) {
globalHandler = handler
}
// State
@Volatile private var connecting: Boolean = false
@Volatile private var session: DefaultClientWebSocketSession? = null
fun connect() {
if (connecting) return
connecting = true
scope.launch {
while (isActive) {
try {
ApiClient.http.webSocket(
method = HttpMethod.Get,
host = API_HOST,
request = {
url {
protocol = URLProtocol.WSS
host = "fromchat.ru"
encodedPath = "/api/chat/ws"
}
}
) {
session = this
for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue
try {
val msg = json.decodeFromString(WebSocketMessage.serializer(), text)
globalHandler?.invoke(msg)
_messages.emit(msg)
} catch (_: Throwable) {
// ignore malformed
}
}
}
} catch (_: Throwable) {
delay(3000)
} finally {
session = null
}
}
}
}
suspend fun send(message: WebSocketMessage) {
val payload = json.encodeToString(WebSocketMessage.serializer(), message)
ApiClient.http.webSocket(
method = HttpMethod.Get,
host = API_HOST,
request = {
url {
protocol = URLProtocol.WSS
host = "fromchat.ru"
encodedPath = "/api/chat/ws"
}
}
) {
send(Frame.Text(payload))
}
}
@OptIn(DelicateCoroutinesApi::class)
suspend fun request(message: WebSocketMessage, timeoutMs: Long = 10_000): WebSocketMessage? {
val ch = Channel<WebSocketMessage>(capacity = 1)
val handler: (WebSocketMessage) -> Unit = {
ch.trySend(it)
}
setGlobalMessageHandler(handler)
return try {
send(message)
withTimeout(timeoutMs) { ch.receive() }
} catch (_: TimeoutCancellationException) {
null
} finally {
setGlobalMessageHandler(null)
ch.close()
}
}
fun shutdown() {
scope.cancel()
}
}
+14 -1
View File
@@ -1,10 +1,23 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.chat.ChatScreen
@Composable @Composable
fun App() { fun App() {
FromChatTheme { FromChatTheme {
val (route, setRoute) = remember { mutableStateOf("login") }
when (route) {
"login" -> LoginScreen(
onLoginSuccess = { setRoute("chat") },
onNavigateToRegister = { setRoute("register") }
)
"register" -> RegisterScreen(onRegistered = { setRoute("login") })
"chat" -> ChatScreen()
}
} }
} }
@@ -0,0 +1,103 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
RowHeader()
if (alert != null) {
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Имя пользователя") },
singleLine = true
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Spacer(Modifier.height(12.dp))
Button(onClick = {
if (username.isBlank() || password.isBlank()) {
alert = "Пожалуйста, заполните все поля"
return@Button
}
CoroutineScope(Dispatchers.IO).launch {
try {
ApiClient.login(LoginRequest(username, password))
onLoginSuccess()
} catch (e: Exception) {
alert = "Неверное имя пользователя или пароль"
}
}
}) { Text("Войти") }
Spacer(Modifier.height(8.dp))
Button(onClick = onNavigateToRegister) { Text("Зарегистрируйтесь") }
}
}
}
}
@Composable
private fun RowHeader() {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.AutoMirrored.Filled.Login, contentDescription = null)
Text("Добро пожаловать!")
Text("Войдите в свой аккаунт", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
}
}
@@ -0,0 +1,115 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
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.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import ru.fromchat.api.ApiClient
import ru.fromchat.api.RegisterRequest
@Composable
fun RegisterScreen(
onRegistered: () -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Card(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(16.dp)) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.PersonAdd, contentDescription = null)
Text("Регистрация")
Text("Создайте новый аккаунт", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
}
if (alert != null) {
Text(text = alert!!, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Имя пользователя") },
singleLine = true
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text("Подтвердите пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
Spacer(Modifier.height(12.dp))
Button(onClick = {
if (username.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
alert = "Пожалуйста, заполните все поля"
return@Button
}
if (password != confirmPassword) {
alert = "Пароли не совпадают"
return@Button
}
if (username.length !in 3..20) {
alert = "Имя пользователя должно быть от 3 до 20 символов"
return@Button
}
if (password.length !in 5..50) {
alert = "Пароль должен быть от 5 до 50 символов"
return@Button
}
CoroutineScope(Dispatchers.IO).launch {
try {
ApiClient.register(RegisterRequest(username, password, confirmPassword))
onRegistered()
} catch (e: Exception) {
alert = "Ошибка при регистрации"
}
}
}) { Text("Зарегистрироваться") }
}
}
}
}
@@ -0,0 +1,86 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
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.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
data class ChatMessage(val author: String, val content: String)
@OptIn(DelicateCoroutinesApi::class)
@Composable
fun BaseChat(
chatTitle: String
) {
val messages = remember { mutableStateListOf<ChatMessage>() }
var composing by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
WebSocketManager.connect()
WebSocketManager.setGlobalMessageHandler { msg ->
// Placeholder: append raw data content if present
val text = msg.data?.toString() ?: return@setGlobalMessageHandler
messages.add(ChatMessage(author = "Server", content = text))
}
}
Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Text(chatTitle)
LazyColumn(modifier = Modifier.weight(1f)) {
items(messages) { m ->
Text("${m.author}: ${m.content}")
}
}
Row {
OutlinedTextField(
value = composing,
onValueChange = { composing = it },
modifier = Modifier.weight(1f),
label = { Text("Сообщение") }
)
Button(onClick = {
if (composing.isNotBlank()) {
// Note: server schema for public chat messages is not specified in the repo.
// We send a generic WebSocketMessage to match frontend type contract.
val payload = WebSocketMessage(
type = "public_chat_message",
data = buildJsonObject {
put("content", JsonPrimitive(composing))
put("chatName", JsonPrimitive(chatTitle))
}
)
// Fire and forget
GlobalScope.launch { WebSocketManager.send(payload) }
messages.add(ChatMessage(author = "Вы", content = composing))
composing = ""
}
}) { Text("Отправить") }
}
}
}
}
@@ -0,0 +1,74 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.unit.dp
enum class ChatTab { CHATS, CONTACTS, DMS }
@Composable
fun ChatScreen() {
var selectedTab by remember { mutableStateOf(ChatTab.CHATS) }
var activeChat by remember { mutableStateOf<String?>(null) }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == ChatTab.CHATS,
onClick = { selectedTab = ChatTab.CHATS },
label = { Text("Чаты") },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == ChatTab.CONTACTS,
onClick = { selectedTab = ChatTab.CONTACTS },
label = { Text("Контакты") },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == ChatTab.DMS,
onClick = { selectedTab = ChatTab.DMS },
label = { Text("ЛС") },
icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
)
}
}
) { paddingValues ->
Column(modifier = Modifier.fillMaxSize().padding(paddingValues).padding(12.dp)) {
when (selectedTab) {
ChatTab.CHATS -> {
if (activeChat == null) {
Text("Общий чат", modifier = Modifier.clickable { activeChat = "Общий чат" }.padding(8.dp))
Text("Общий чат 2", modifier = Modifier.clickable { activeChat = "Общий чат 2" }.padding(8.dp))
} else {
BaseChat(chatTitle = activeChat!!)
}
}
ChatTab.CONTACTS -> {
Text("Скоро будет...")
}
ChatTab.DMS -> {
Text("Скоро будет...")
}
}
}
}
}