Implement server config

This commit is contained in:
2025-12-07 15:29:27 +03:00
Unverified
parent b95820de6c
commit c5f4b4f62f
22 changed files with 1016 additions and 141 deletions
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -10,7 +12,9 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.FromChat" android:theme="@style/Theme.FromChat"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true"
android:name=".App"
android:networkSecurityConfig="@xml/network_security_config">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -0,0 +1,11 @@
package ru.fromchat
import android.app.Application
import com.pr0gramm3r101.utils.UtilsLibrary
class App: Application() {
override fun onCreate() {
super.onCreate()
UtilsLibrary.init(this)
}
}
@@ -0,0 +1,19 @@
package ru.fromchat.ui
import android.os.Build
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (dynamicColor && Build.VERSION.SDK_INT >= 31) {
if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
else dynamicLightColorScheme(LocalContext.current)
} else {
if (darkTheme) darkColorScheme()
else lightColorScheme()
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>
@@ -0,0 +1,17 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M0 0 h 1024 v 1024 h -1024 L0 0"
android:fillColor="#4484f4"
android:fillType="evenOdd"
android:strokeColor="#00000000" />
<path
android:pathData="M500.1,129.1C497.2,129.6 491.7,131.1 488,132.4C483.9,133.8 427.2,165.8 339.9,216C262.1,260.7 196.7,298.5 194.5,300C188.8,303.9 178.1,315.6 174.2,322.2C170,329.3 166.4,339.6 165,348.5C164.3,353.3 164,407.1 164.2,522L164.5,688.5 167.1,696.2C172.4,711.6 183,726 195.3,734.3C205,741 481.1,890.9 487.3,892.9C504.8,898.6 523.3,897.9 539.4,891.1C547.3,887.7 827.3,721.9 833.7,716.8C841.4,710.7 848.2,701.9 853.1,691.5C860.4,675.9 860,686.3 860,509.5C860,331.6 860.5,344.2 852.5,327.5C846.2,314.4 837,304.3 824.3,296.7C811.8,289.2 549.2,138.1 544.5,135.8C532.1,129.5 513.5,126.7 500.1,129.1M502.5,149.4C502,149.6 499,150.6 496,151.5C489,153.7 208.7,314.7 202.8,320C200.4,322.1 198.7,324 199,324.3C199.3,324.6 225.1,339.5 256.5,357.5C292.1,377.9 313.9,389.9 314.6,389.3C315.3,388.9 317.3,387.3 319.1,385.8C324.2,381.9 492.9,284.6 498.6,282.4C505.5,279.7 519.6,279.9 526.5,282.7C532.9,285.2 702.2,383.2 707.2,387.3L710.9,390.3 768.5,357.4L826.1,324.5 821.6,320.4C816.6,315.8 540.6,156.2 530.5,152.1C525.7,150.2 522.3,149.6 514,149.4C508.2,149.2 503,149.2 502.5,149.4M186.2,347.1C184.6,353.8 184.5,680.2 186.1,687C188.1,696 193,704.2 200.4,711.4C207.3,718.1 211.3,720.4 348.5,795.1C426,837.4 491.4,872.6 493.9,873.5C502.9,876.5 502,883.5 502,809.6L502,744.2 497.7,742.7C493.2,741.2 327.2,650.6 320.4,645.9C314.5,641.9 310.2,636.6 306.6,629L303.5,622.5 303.5,515.3L303.5,408.2 245.8,375.1C214,356.9 187.9,342 187.7,342C187.5,342 186.8,344.3 186.2,347.1M779.1,375L721.8,407.9 721.4,507.7C721.1,562.6 720.7,609.3 720.4,611.6C719.6,618 714.5,627.6 709.2,632.8C705.7,636.1 679.1,652.4 617.9,688.7C570.4,716.9 529.3,740.8 526.7,742L522,744.1 522,809.6L522,875 524.8,874.4C530.2,873.2 543.7,865.4 679.1,785.5C828,697.6 826.1,698.9 832.8,686.3C839.7,673.1 839.3,685.9 839.4,509.5C839.5,370.5 839.3,350 838,346.4L836.5,342.2 779.1,375M504.5,357.1C502.4,357.6 474.2,373.2 442,391.8C378.9,428.2 376.8,429.6 373,439.6C371,445.1 371,446.4 371.2,515.8L371.5,586.5 373.8,591.1C375,593.7 377.8,597.5 380,599.7C385.5,605.1 497.7,666.5 505.1,668.1C511.3,669.4 518.9,668.6 524.3,666C533.3,661.6 639.5,598.2 643.3,594.9C648.3,590.6 651.6,584.3 653.1,576.7C653.7,573.1 654,548.2 653.8,507.8L653.5,444.5 650.2,437.8C648.4,434.2 645.5,429.9 643.7,428.3C640.3,425.4 529.5,361 523.4,358.4C518.9,356.5 509.5,355.8 504.5,357.1"
android:fillColor="#fafbfc"
android:fillType="evenOdd"
android:strokeColor="#00000000" />
</vector>
@@ -7,6 +7,7 @@
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="home">Home</string> <string name="home">Home</string>
<string name="about">About app</string> <string name="about">About app</string>
<string name="app_desc">A 100% free, open source and private messenger.</string>
<!-- Authentication --> <!-- Authentication -->
<string name="welcome">Welcome!</string> <string name="welcome">Welcome!</string>
@@ -36,6 +37,26 @@
<string name="chat_last_mesaage">You: Last message</string> <string name="chat_last_mesaage">You: Last message</string>
<string name="message_placeholder">Type a message...</string> <string name="message_placeholder">Type a message...</string>
<!-- Server Configuration -->
<string name="server_config_title">Server Configuration</string>
<string name="server_config_subtitle">Enter server details to connect</string>
<string name="server_url_label">Server URL</string>
<string name="server_url_hint">fromchat.ru</string>
<string name="https_enabled">Use HTTPS</string>
<string name="save_continue">Save &amp; Continue</string>
<!-- Settings -->
<string name="change_server">Change Server</string>
<string name="change_server_d">Changes the FromChat instance you are connecting to. This will log you out of your account.</string>
<string name="logout">Logout</string>
<string name="materialYou">Enable Material You theming</string>
<string name="materialYou_d">Enable the dynamic theme based on your wallpaper. Works only on Android 12 or higher.</string>
<string name="theme">Theme</string>
<string name="as_system">As system</string>
<string name="light">Light</string>
<string name="dark">Dark</string>
<!-- Error Messages --> <!-- Error Messages -->
<string name="error_unexpected">Unexpected error</string> <string name="error_unexpected">Unexpected error</string>
<string name="error_invalid_credentials">Invalid username or password</string> <string name="error_invalid_credentials">Invalid username or password</string>
@@ -3,9 +3,6 @@ package ru.fromchat
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char 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 { val DATETIME_FORMAT = LocalDateTime.Format {
day() day()
char('.') char('.')
@@ -18,7 +18,7 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.API_HOST import ru.fromchat.core.config.Config
import ru.fromchat.utils.failOnError import ru.fromchat.utils.failOnError
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
@@ -52,15 +52,13 @@ object ApiClient {
@Volatile @Volatile
var token: String? = null var token: String? = null
private set
@Volatile @Volatile
var user: User? = null var user: User? = null
private set
suspend fun login(request: LoginRequest) = suspend fun login(request: LoginRequest) =
http http
.post("$API_HOST/api/login") { .post("${Config.getApiBaseUrl()}/login") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") }) setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
} }
@@ -73,7 +71,7 @@ object ApiClient {
suspend fun register(request: RegisterRequest) = suspend fun register(request: RegisterRequest) =
http http
.post("$API_HOST/api/register") { .post("${Config.getApiBaseUrl()}/register") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(request) setBody(request)
} }
@@ -81,7 +79,7 @@ object ApiClient {
suspend fun getMessages() = suspend fun getMessages() =
http http
.get("$API_HOST/api/get_messages") { .get("${Config.getApiBaseUrl()}/get_messages") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
} }
.failOnError() .failOnError()
@@ -89,7 +87,7 @@ object ApiClient {
suspend fun send(message: String) = suspend fun send(message: String) =
http http
.post("$API_HOST/api/send_message") { .post("${Config.getApiBaseUrl()}/send_message") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token!!) bearerAuth(token!!)
setBody(SendMessageRequest(message)) setBody(SendMessageRequest(message))
@@ -99,7 +97,7 @@ object ApiClient {
suspend fun editMessage(messageId: Int, content: String) = suspend fun editMessage(messageId: Int, content: String) =
http http
.put("$API_HOST/api/edit_message/$messageId") { .put("${Config.getApiBaseUrl()}/edit_message/$messageId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token!!) bearerAuth(token!!)
setBody(EditMessageRequest(content)) setBody(EditMessageRequest(content))
@@ -109,9 +107,20 @@ object ApiClient {
suspend fun deleteMessage(messageId: Int) = suspend fun deleteMessage(messageId: Int) =
http http
.delete("$API_HOST/api/delete_message/$messageId") { .delete("${Config.getApiBaseUrl()}/delete_message/$messageId") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
bearerAuth(token!!) bearerAuth(token!!)
} }
.failOnError() .failOnError()
suspend fun logout(authToken: String) {
// Don't throw on logout errors, just try to logout
try {
http.get("${Config.getApiBaseUrl()}/logout") {
bearerAuth(authToken)
}
} catch (e: Exception) {
// Ignore logout errors
}
}
} }
@@ -19,7 +19,7 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.WS_API_HOST import ru.fromchat.core.config.Config
import ru.fromchat.core.Logger import ru.fromchat.core.Logger
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.coroutines.suspendCoroutine import kotlin.coroutines.suspendCoroutine
@@ -55,10 +55,12 @@ object WebSocketManager {
scope.launch { scope.launch {
while (isActive) { while (isActive) {
try { try {
val wsUrl = Config.getWebSocketUrl()
Logger.d("WebSocketManager", "Connecting to: $wsUrl")
ApiClient.http.webSocket( ApiClient.http.webSocket(
method = HttpMethod.Get, method = HttpMethod.Get,
request = { request = {
url("$WS_API_HOST/api/chat/ws") url(wsUrl)
} }
) { ) {
session = this session = this
@@ -0,0 +1,17 @@
package ru.fromchat.core
import ru.fromchat.ui.Theme
import com.pr0gramm3r101.utils.settings.Settings
object Settings {
private val settings = Settings.Companion()
var materialYou: Boolean
get() = settings.getBoolean("materialYou", true)
set(value) = settings.putBoolean("materialYou", value)
var theme: Theme
get() = Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)]
set(value) = settings.putInt("theme", value.ordinal)
}
@@ -0,0 +1,55 @@
package ru.fromchat.core.config
import com.pr0gramm3r101.utils.storage.ServerConfigData
import com.pr0gramm3r101.utils.storage.ServerConfigStorage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Application configuration
*/
object Config {
private val _serverConfig = MutableStateFlow<ServerConfigData?>(null)
val serverConfig: StateFlow<ServerConfigData?> = _serverConfig.asStateFlow()
/**
* Initialize configuration by loading from storage
*/
suspend fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig()
}
/**
* Update server configuration
*/
suspend fun updateServerConfig(config: ServerConfigData) {
ServerConfigStorage.saveConfig(config)
_serverConfig.value = config
}
/**
* Get API base URL based on current server configuration
*/
fun getApiBaseUrl(): String {
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
val protocol = if (config.httpsEnabled) "https" else "http"
return "$protocol://${config.serverUrl}/api"
}
/**
* Get WebSocket URL based on current server configuration
*/
fun getWebSocketUrl(): String {
val config = _serverConfig.value ?: ServerConfigData("fromchat.ru", true)
val protocol = if (config.httpsEnabled) "wss" else "ws"
return "$protocol://${config.serverUrl}/api/chat/ws"
}
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig(): Boolean {
return ServerConfigStorage.hasConfiguration()
}
}
@@ -0,0 +1,105 @@
@file:Suppress("NOTHING_TO_INLINE")
package ru.fromchat.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.app_desc
import ru.fromchat.app_icon
import ru.fromchat.app_name
import ru.fromchat.back
@OptIn(ExperimentalMaterial3Api::class)
@Composable
inline fun AboutScreen() {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(
stringResource(Res.string.about),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
},
) { innerPadding ->
Box(Modifier.padding(innerPadding)) {
Column(
Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.verticalScroll(rememberScrollState())
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(Modifier.padding(bottom = 10.dp)) {
Image(
painter = painterResource(Res.drawable.app_icon),
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.size(70.dp)
)
}
Text(
text = stringResource(Res.string.app_name),
fontSize = 20.sp,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp)
)
Text(
text = stringResource(Res.string.app_desc),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -3,38 +3,81 @@ package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
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.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
import ru.fromchat.ui.auth.RegisterScreen import ru.fromchat.ui.auth.RegisterScreen
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("") } val LocalNavController = compositionLocalOf<NavController> { error("") }
@Composable @Composable
fun App() { fun App() {
var startDestination by remember { mutableStateOf<String?>(null) }
// Initialize config and check server configuration on startup
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
WebSocketManager.connect() coroutineScope {
launch {
try {
// Initialize config
Config.initialize()
// Check if server is configured
val serverConfigured = Config.hasServerConfig()
// Determine which screen to show
startDestination = if (!serverConfigured) {
"serverConfig"
} else {
"login"
}
} catch (e: Exception) {
// On error, start with server config
startDestination = "serverConfig"
}
}
}
} }
FromChatTheme(dynamicColor = false) { FromChatTheme {
val navController = rememberNavController() val navController = rememberNavController()
val animationSpec = tween<IntOffset>(400) val animationSpec = tween<IntOffset>(400)
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController LocalNavController provides navController
) { ) {
if (startDestination == null) {
Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "login", startDestination = startDestination!!,
enterTransition = { enterTransition = {
slideIntoContainer( slideIntoContainer(
Start, Start,
@@ -60,11 +103,18 @@ fun App() {
) )
} }
) { ) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") { composable("login") {
LoginScreen( LoginScreen(
onLoginSuccess = { onLoginSuccess = {
navController.navigate("chat") // Ensure WebSocket is connected after login
navController.popBackStack() WebSocketManager.connect()
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
}, },
onNavigateToRegister = { navController.navigate("register") } onNavigateToRegister = { navController.navigate("register") }
) )
@@ -76,9 +126,22 @@ fun App() {
) )
} }
composable("chat") { MainScreen() } composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") { /* PublicChatScreen() */ } composable("chats/publicChat") { /* PublicChatScreen() */ }
composable("about") {
AboutScreen()
}
}
} }
} }
} }
@@ -0,0 +1,19 @@
package ru.fromchat.ui
import androidx.navigation.NavController
/**
* Clears the entire back stack except the current screen
*/
fun NavController.wipeBackStack() {
// Keep popping until there are no previous entries
var entry = previousBackStackEntry
while (entry != null) {
val destinationId = entry.destination.id
val success = popBackStack(destinationId, inclusive = true)
if (!success) break
// Check again after popping
entry = previousBackStackEntry
}
}
@@ -1,95 +1,117 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import ru.fromchat.core.Settings
private val DarkColorScheme = darkColorScheme( enum class Theme {
primary = Color(0xFFDBB9F9), AsSystem,
onPrimary = Color(0xFF3E2458), Light,
primaryContainer = Color(0xFF563B71), Dark
onPrimaryContainer = Color(0xFFF0DBFF), }
secondary = Color(0xFFD0C1DA),
onSecondary = Color(0xFF362C3F), var dynamicThemeEnabled by mutableStateOf(
secondaryContainer = Color(0xFF4D4356), runCatching { Settings.materialYou }.getOrNull() == true
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( var theme by mutableStateOf(
primary = Color(0xFF1F6586), runCatching { Settings.theme }.getOrNull() ?: Theme.AsSystem
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
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
@Composable @Composable
fun FromChatTheme( fun FromChatTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = when (theme) {
dynamicColor: Boolean = false, // Disabled for multiplatform compatibility Theme.AsSystem -> isSystemInDarkTheme()
Theme.Light -> false
Theme.Dark -> true
},
dynamicColor: Boolean = dynamicThemeEnabled,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme var colorScheme = getColorScheme(darkTheme, dynamicColor)
val primary by animateColorAsState(colorScheme.primary)
val onPrimary by animateColorAsState(colorScheme.onPrimary)
val primaryContainer by animateColorAsState(colorScheme.primaryContainer)
val onPrimaryContainer by animateColorAsState(colorScheme.onPrimaryContainer)
val secondary by animateColorAsState(colorScheme.secondary)
val onSecondary by animateColorAsState(colorScheme.onSecondary)
val secondaryContainer by animateColorAsState(colorScheme.secondaryContainer)
val onSecondaryContainer by animateColorAsState(colorScheme.onSecondaryContainer)
val tertiary by animateColorAsState(colorScheme.tertiary)
val onTertiary by animateColorAsState(colorScheme.onTertiary)
val tertiaryContainer by animateColorAsState(colorScheme.tertiaryContainer)
val onTertiaryContainer by animateColorAsState(colorScheme.onTertiaryContainer)
val error by animateColorAsState(colorScheme.error)
val onError by animateColorAsState(colorScheme.onError)
val errorContainer by animateColorAsState(colorScheme.errorContainer)
val onErrorContainer by animateColorAsState(colorScheme.onErrorContainer)
val background by animateColorAsState(colorScheme.background)
val onBackground by animateColorAsState(colorScheme.onBackground)
val surface by animateColorAsState(colorScheme.surface)
val onSurface by animateColorAsState(colorScheme.onSurface)
val surfaceVariant by animateColorAsState(colorScheme.surfaceVariant)
val onSurfaceVariant by animateColorAsState(colorScheme.onSurfaceVariant)
val outline by animateColorAsState(colorScheme.outline)
val outlineVariant by animateColorAsState(colorScheme.outlineVariant)
val scrim by animateColorAsState(colorScheme.scrim)
val inverseSurface by animateColorAsState(colorScheme.inverseSurface)
val inverseOnSurface by animateColorAsState(colorScheme.inverseOnSurface)
val inversePrimary by animateColorAsState(colorScheme.inversePrimary)
val surfaceDim by animateColorAsState(colorScheme.surfaceDim)
val surfaceBright by animateColorAsState(colorScheme.surfaceBright)
val surfaceContainerLowest by animateColorAsState(colorScheme.surfaceContainerLowest)
val surfaceContainerLow by animateColorAsState(colorScheme.surfaceContainerLow)
val surfaceContainer by animateColorAsState(colorScheme.surfaceContainer)
val surfaceContainerHigh by animateColorAsState(colorScheme.surfaceContainerHigh)
val surfaceContainerHighest by animateColorAsState(colorScheme.surfaceContainerHighest)
colorScheme = colorScheme.copy(
primary = primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = onPrimaryContainer,
secondary = secondary,
onSecondary = onSecondary,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSecondaryContainer,
tertiary = tertiary,
onTertiary = onTertiary,
tertiaryContainer = tertiaryContainer,
onTertiaryContainer = onTertiaryContainer,
error = error,
onError = onError,
errorContainer = errorContainer,
onErrorContainer = onErrorContainer,
background = background,
onBackground = onBackground,
surface = surface,
onSurface = onSurface,
surfaceVariant = surfaceVariant,
onSurfaceVariant = onSurfaceVariant,
outline = outline,
outlineVariant = outlineVariant,
scrim = scrim,
inverseSurface = inverseSurface,
inverseOnSurface = inverseOnSurface,
inversePrimary = inversePrimary,
surfaceDim = surfaceDim,
surfaceBright = surfaceBright,
surfaceContainerLowest = surfaceContainerLowest,
surfaceContainerLow = surfaceContainerLow,
surfaceContainer = surfaceContainer,
surfaceContainerHigh = surfaceContainerHigh,
surfaceContainerHighest = surfaceContainerHighest
)
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@@ -28,12 +30,15 @@ import ru.fromchat.chats
import ru.fromchat.coming_soon import ru.fromchat.coming_soon
import ru.fromchat.contacts import ru.fromchat.contacts
import ru.fromchat.dms import ru.fromchat.dms
import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController
import ru.fromchat.utils.exclude import ru.fromchat.utils.exclude
@Suppress("AssignedValueIsNeverRead") @Suppress("AssignedValueIsNeverRead")
@Composable @Composable
fun MainScreen() { fun MainScreen(onLogout: () -> Unit = {}) {
var selectedTab by remember { mutableStateOf("chats") } var selectedTab by rememberSaveable { mutableStateOf("chats") }
val navController = LocalNavController.current
Scaffold( Scaffold(
bottomBar = { bottomBar = {
@@ -56,6 +61,12 @@ fun MainScreen() {
label = { Text(stringResource(Res.string.dms)) }, label = { Text(stringResource(Res.string.dms)) },
icon = { Icon(Icons.Filled.Mail, contentDescription = null) } icon = { Icon(Icons.Filled.Mail, contentDescription = null) }
) )
NavigationBarItem(
selected = selectedTab == "settings",
onClick = { selectedTab = "settings" },
label = { Text(stringResource(Res.string.settings)) },
icon = { Icon(Icons.Filled.Settings, contentDescription = null) }
)
} }
}, },
contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top), contentWindowInsets = WindowInsets.safeDrawing.exclude(WindowInsetsSides.Top),
@@ -74,6 +85,9 @@ fun MainScreen() {
"dms" -> { "dms" -> {
Text(stringResource(Res.string.coming_soon)) Text(stringResource(Res.string.coming_soon))
} }
"settings" -> {
SettingsTab(onLogout = onLogout)
}
} }
} }
} }
@@ -0,0 +1,248 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.Brush
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Wallpaper
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.ListItem
import com.pr0gramm3r101.components.SwitchListItem
import com.pr0gramm3r101.utils.materialYouAvailable
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.about
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.as_system
import ru.fromchat.change_server
import ru.fromchat.change_server_d
import ru.fromchat.core.Settings
import ru.fromchat.dark
import ru.fromchat.light
import ru.fromchat.logout
import ru.fromchat.materialYou
import ru.fromchat.materialYou_d
import ru.fromchat.settings
import ru.fromchat.theme
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.Theme
import ru.fromchat.ui.dynamicThemeEnabled
import ru.fromchat.ui.theme
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun SettingsTab(
onLogout: () -> Unit
) {
TabBase {
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = {
Text(
text = stringResource(Res.string.settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
actions = {
IconButton(onClick = { navController.navigate("about") }) {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = stringResource(Res.string.about)
)
}
},
scrollBehavior = scrollBehavior
)
},
) { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
var materialYouSwitch by remember {
mutableStateOf(
Settings.materialYou && materialYouAvailable
)
}
Category(Modifier.padding(top = 16.dp)) {
// Material You
SwitchListItem(
headline = stringResource(Res.string.materialYou),
supportingText = stringResource(Res.string.materialYou_d),
enabled = materialYouAvailable,
checked = materialYouSwitch,
onCheckedChange = {
materialYouSwitch = it
Settings.materialYou = it
dynamicThemeEnabled = it
},
divider = true,
dividerColor = MaterialTheme.colorScheme.surface,
dividerThickness = 2.dp,
leadingContent = {
Icon(
imageVector = Icons.Filled.Wallpaper,
contentDescription = null
)
}
)
// Theme
ListItem(
headline = stringResource(Res.string.theme),
leadingContent = {
Icon(
imageVector = Icons.Filled.Brush,
contentDescription = null
)
},
bottomContent = {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
var selectedIndex by remember { mutableIntStateOf(Settings.theme.ordinal) }
val options = listOf(
stringResource(Res.string.as_system),
stringResource(Res.string.light),
stringResource(Res.string.dark)
)
options.forEachIndexed { index, label ->
FilterChip(
onClick = {
selectedIndex = index
Settings.theme = Theme.entries[index]
theme = Theme.entries[index]
},
selected = index == selectedIndex,
leadingIcon = {
if (index == 0) {
Spacer(Modifier.width(16.dp))
}
when (index) {
0 -> Icon(Icons.Filled.Settings, null)
1 -> Icon(Icons.Filled.LightMode, null)
2 -> Icon(Icons.Filled.DarkMode, null)
}
},
label = {
Text(
text = label,
overflow = TextOverflow.Ellipsis
)
}
)
}
}
}
)
}
// Server Configuration
Category(Modifier.padding(top = 16.dp)) {
ListItem(
headline = stringResource(Res.string.change_server),
supportingText = stringResource(Res.string.change_server_d),
onClick = {
// Logout and navigate to server config when server changes
scope.launch {
// Logout from current server
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors (server might be unreachable)
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
WebSocketManager.shutdown()
navController.navigate("serverConfig")
}
},
divider = true,
dividerColor = MaterialTheme.colorScheme.surface,
dividerThickness = 2.dp,
leadingContent = {
Icon(Icons.Filled.Storage, null)
}
)
ListItem(
headline = stringResource(Res.string.logout),
leadingContent = {
Icon(Icons.AutoMirrored.Filled.Logout, null)
},
onClick = {
scope.launch {
// Logout
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
// Shutdown WebSocket
WebSocketManager.shutdown()
// Navigate back to auth
onLogout()
}
}
)
}
}
}
}
}
@@ -0,0 +1,37 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import com.pr0gramm3r101.utils.resetFocus
@Composable
inline fun TabBase(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = interactionSource,
indication = null
) {
resetFocus(keyboardController, focusManager)
}
.then(modifier)
) {
content()
}
}
@@ -0,0 +1,183 @@
package ru.fromchat.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
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.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
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.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.storage.ServerConfigData
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager
import ru.fromchat.back
import ru.fromchat.core.config.Config
import ru.fromchat.https_enabled
import ru.fromchat.save_continue
import ru.fromchat.server_config_subtitle
import ru.fromchat.server_config_title
import ru.fromchat.server_url_hint
import ru.fromchat.server_url_label
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.wipeBackStack
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerConfigScreen() {
val navController = LocalNavController.current
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available
var serverUrl by remember { mutableStateOf("fromchat.ru") }
var httpsEnabled by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
val config = Config.serverConfig.value
if (config != null) {
serverUrl = config.serverUrl
httpsEnabled = config.httpsEnabled
}
}
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = navController::navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
},
scrollBehavior = scrollBehavior
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.windowInsetsPadding(WindowInsets.ime)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(Res.string.server_config_title),
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(Res.string.server_config_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text(stringResource(Res.string.server_url_label)) },
placeholder = { Text(stringResource(Res.string.server_url_hint)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = httpsEnabled,
onCheckedChange = { httpsEnabled = it }
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(Res.string.https_enabled))
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = {
isLoading = true
scope.launch {
val config = ServerConfigData(serverUrl, httpsEnabled)
Config.updateServerConfig(config)
// Ensure we're logged out when server config changes
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
// Shutdown WebSocket (will reconnect on login)
WebSocketManager.shutdown()
// Clear entire back stack except current screen
navController.wipeBackStack()
// Navigate to login
navController.navigate("login")
}
},
enabled = !isLoading && serverUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(Res.string.save_continue))
}
}
}
}
@@ -0,0 +1,10 @@
package ru.fromchat.ui
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
@Composable
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
if (darkTheme) darkColorScheme()
else lightColorScheme()
+5
View File
@@ -46,5 +46,10 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict> </dict>
</plist> </plist>
@@ -6,9 +6,12 @@ import java.lang.ref.WeakReference
actual object UtilsLibrary { actual object UtilsLibrary {
private var init = false private var init = false
private lateinit var _context: WeakReference<Context> private lateinit var _context: WeakReference<Context>
internal val context get() = _context.get()!! val context get() = _context.get()!!
actual fun init(vararg args: Any) { actual fun init(vararg args: Any) {
if (!init) {
_context = WeakReference(args[0] as Context) _context = WeakReference(args[0] as Context)
init = true
}
} }
} }