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
@@ -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))
}
}
}
}
}