Implement proper iOS structure

This commit is contained in:
2025-12-06 12:17:40 +03:00
Unverified
parent 7696d26fd3
commit b95820de6c
36 changed files with 811 additions and 87 deletions
@@ -5,6 +5,7 @@ import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
suspend inline fun <Response> apiRequest(
unexpectedError: String,
onError: (String, Exception) -> Unit = { _, _ -> },
onSuccess: (Response) -> Unit = {},
request: suspend () -> Response
@@ -17,7 +18,7 @@ suspend inline fun <Response> apiRequest(
val message = if (e.response.status.value in arrayOf(401, 403)) {
e.response.body<ErrorResponse>().detail
} else {
"Unexpected error"
unexpectedError
}
Logger.e("API", "API request failed: $message", e)
@@ -26,7 +27,7 @@ suspend inline fun <Response> apiRequest(
return Result.failure(e)
} catch (e: Exception) {
Logger.e("API", "API request failed: ${e.message}", e)
onError("Unexpected error", e)
onError(unexpectedError, e)
return Result.failure(e)
}
}
@@ -1,6 +1,5 @@
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
@@ -10,6 +9,7 @@ import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
@@ -20,6 +20,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import ru.fromchat.WS_API_HOST
import ru.fromchat.core.Logger
import kotlin.concurrent.Volatile
import kotlin.coroutines.suspendCoroutine
object WebSocketManager {
@@ -41,7 +43,8 @@ object WebSocketManager {
}
// State
@Volatile private var connecting: Boolean = false
@Volatile
private var connecting: Boolean = false
@Volatile private var session: DefaultClientWebSocketSession? = null
fun connect() {
@@ -6,3 +6,4 @@ expect object Logger {
fun w(tag: String, message: String, throwable: Throwable? = null)
fun e(tag: String, message: String, throwable: Throwable? = null)
}
@@ -62,7 +62,10 @@ fun App() {
) {
composable("login") {
LoginScreen(
onLoginSuccess = { navController.navigate("chat") },
onLoginSuccess = {
navController.navigate("chat")
navController.popBackStack()
},
onNavigateToRegister = { navController.navigate("register") }
)
}
@@ -24,22 +24,32 @@ 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 com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import kotlinx.coroutines.launch
import ru.fromchat.R
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields
import ru.fromchat.login
import ru.fromchat.login_d
import ru.fromchat.password
import ru.fromchat.register_button
import ru.fromchat.ui.RowHeader
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import ru.fromchat.username
import ru.fromchat.welcome
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
val errorUnexpected = stringResource(Res.string.error_unexpected)
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
@@ -63,8 +73,8 @@ fun LoginScreen(
) {
RowHeader(
icon = Icons.AutoMirrored.Filled.Login,
title = stringResource(R.string.welcome),
subtitle = stringResource(R.string.login_d)
title = stringResource(Res.string.welcome),
subtitle = stringResource(Res.string.login_d)
)
if (alert != null) {
@@ -74,25 +84,25 @@ fun LoginScreen(
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.username)) },
label = { Text(stringResource(Res.string.username)) },
singleLine = true
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
label = { Text(stringResource(Res.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
val alert_error_filling = stringResource(R.string.fill_all_fields)
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
Button(
onClick = {
if (username.isBlank() || password.isBlank()) {
alert = alert_error_filling
alert = alertErrorFilling
return@Button
}
@@ -101,6 +111,7 @@ fun LoginScreen(
val derived = deriveAuthSecret(username.trim(), password.trim())
apiRequest(
unexpectedError = errorUnexpected,
onError = { message, _ ->
alert = message
},
@@ -111,11 +122,11 @@ fun LoginScreen(
}
}
) {
Text(stringResource(R.string.login))
Text(stringResource(Res.string.login))
}
Button(onClick = onNavigateToRegister) {
Text(stringResource(R.string.register_button))
Text(stringResource(Res.string.register_button))
}
}
}
@@ -27,22 +27,38 @@ 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 com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import kotlinx.coroutines.launch
import ru.fromchat.R
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.RegisterRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.back
import ru.fromchat.confirm_password
import ru.fromchat.display_name
import ru.fromchat.display_name_error
import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields
import ru.fromchat.password
import ru.fromchat.password_length_error
import ru.fromchat.passwords_dont_match
import ru.fromchat.register
import ru.fromchat.register_button
import ru.fromchat.register_d
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import ru.fromchat.username
import ru.fromchat.username_length_error
@Composable
fun RegisterScreen(
onRegistered: () -> Unit
) {
val errorUnexpected = stringResource(Res.string.error_unexpected)
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
var username by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
@@ -69,8 +85,8 @@ fun RegisterScreen(
) {
RowHeader(
icon = Icons.Filled.PersonAdd,
title = stringResource(R.string.register),
subtitle = stringResource(R.string.register_d)
title = stringResource(Res.string.register),
subtitle = stringResource(Res.string.register_d)
)
if (alert != null) {
@@ -80,21 +96,21 @@ fun RegisterScreen(
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text(stringResource(R.string.username)) },
label = { Text(stringResource(Res.string.username)) },
singleLine = true
)
OutlinedTextField(
value = displayName,
onValueChange = { displayName = it },
label = { Text("Display Name") },
label = { Text(stringResource(Res.string.display_name)) },
singleLine = true
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.password)) },
label = { Text(stringResource(Res.string.password)) },
singleLine = true,
visualTransformation = PasswordVisualTransformation()
)
@@ -102,23 +118,24 @@ fun RegisterScreen(
OutlinedTextField(
value = confirmPassword,
onValueChange = { confirmPassword = it },
label = { Text(stringResource(R.string.confirm_password)) },
label = { Text(stringResource(Res.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)
val alertErrorFilling = stringResource(Res.string.fill_all_fields)
val alertErrorPasswordLittle = stringResource(Res.string.password_length_error)
val alertErrorNameLittle = stringResource(Res.string.username_length_error)
val alertErrorPasswordConfrim = stringResource(Res.string.passwords_dont_match)
val alertErrorDisplayName = stringResource(Res.string.display_name_error)
IconButton(
onClick = { navController.navigateUp() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
contentDescription = stringResource(Res.string.back)
)
}
@@ -126,23 +143,23 @@ fun RegisterScreen(
onClick = {
// Checks
if (username.isBlank() || displayName.isBlank() || password.isBlank() || confirmPassword.isBlank()) {
alert = alert_error_filling
alert = alertErrorFilling
return@Button
}
if (password != confirmPassword) {
alert = alert_error_password_confrim
alert = alertErrorPasswordConfrim
return@Button
}
if (username.length !in 3..20) {
alert = alert_error_name_little
alert = alertErrorNameLittle
return@Button
}
if (displayName.isBlank() || displayName.length > 64) {
alert = "Display name must be between 1 and 64 characters"
alert = alertErrorDisplayName
return@Button
}
if (password.length !in 5..50) {
alert = alert_error_password_little
alert = alertErrorPasswordLittle
return@Button
}
@@ -151,6 +168,7 @@ fun RegisterScreen(
val derived = deriveAuthSecret(username.trim(), password)
apiRequest(
unexpectedError = errorUnexpected,
onError = { message, _ ->
alert = message
},
@@ -168,7 +186,7 @@ fun RegisterScreen(
}
}
) {
Text(stringResource(R.string.register_button))
Text(stringResource(Res.string.register_button))
}
}
}
@@ -11,9 +11,12 @@ 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 org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chat_last_mesaage
import ru.fromchat.chats
import ru.fromchat.public_chat
import ru.fromchat.ui.LocalNavController
@OptIn(ExperimentalMaterial3Api::class)
@@ -28,7 +31,7 @@ fun ChatsTab() {
MediumTopAppBar(
title = {
Text(
text = stringResource(R.string.chats),
text = stringResource(Res.string.chats),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -40,8 +43,8 @@ fun ChatsTab() {
LazyColumn(contentPadding = innerPadding) {
item {
ListItem(
headlineContent = { Text(stringResource(R.string.public_chat)) },
supportingContent = { Text(stringResource(R.string.chat_last_mesaage)) },
headlineContent = { Text(stringResource(Res.string.public_chat)) },
supportingContent = { Text(stringResource(Res.string.chat_last_mesaage)) },
modifier = Modifier.clickable {
navController.navigate("chats/publicChat")
}
@@ -22,10 +22,15 @@ 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 org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chats
import ru.fromchat.coming_soon
import ru.fromchat.contacts
import ru.fromchat.dms
import ru.fromchat.utils.exclude
@Suppress("AssignedValueIsNeverRead")
@Composable
fun MainScreen() {
var selectedTab by remember { mutableStateOf("chats") }
@@ -36,19 +41,19 @@ fun MainScreen() {
NavigationBarItem(
selected = selectedTab == "chats",
onClick = { selectedTab = "chats" },
label = { Text(stringResource(R.string.chats)) },
label = { Text(stringResource(Res.string.chats)) },
icon = { Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "contacts",
onClick = { selectedTab = "contacts" },
label = { Text(stringResource(R.string.contacts)) },
label = { Text(stringResource(Res.string.contacts)) },
icon = { Icon(Icons.Filled.Contacts, contentDescription = null) }
)
NavigationBarItem(
selected = selectedTab == "dms",
onClick = { selectedTab = "dms" },
label = { Text(stringResource(R.string.dms)) },
label = { Text(stringResource(Res.string.dms)) },
icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
)
}
@@ -64,10 +69,10 @@ fun MainScreen() {
when (selectedTab) {
"chats" -> ChatsTab()
"contacts" -> {
Text(stringResource(R.string.coming_soon))
Text(stringResource(Res.string.coming_soon))
}
"dms" -> {
Text(stringResource(R.string.coming_soon))
Text(stringResource(Res.string.coming_soon))
}
}
}
@@ -5,4 +5,5 @@ import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.exclude
import androidx.compose.foundation.layout.only
@Suppress("NOTHING_TO_INLINE")
inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides))