mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement server config
This commit is contained in:
@@ -3,9 +3,6 @@ package ru.fromchat
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.format.char
|
||||
|
||||
const val API_HOST = "https://fromchat.ru"
|
||||
const val WS_API_HOST = "wss://fromchat.ru"
|
||||
|
||||
val DATETIME_FORMAT = LocalDateTime.Format {
|
||||
day()
|
||||
char('.')
|
||||
|
||||
@@ -18,7 +18,7 @@ import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.API_HOST
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.utils.failOnError
|
||||
import kotlin.concurrent.Volatile
|
||||
|
||||
@@ -52,15 +52,13 @@ object ApiClient {
|
||||
|
||||
@Volatile
|
||||
var token: String? = null
|
||||
private set
|
||||
|
||||
@Volatile
|
||||
var user: User? = null
|
||||
private set
|
||||
|
||||
suspend fun login(request: LoginRequest) =
|
||||
http
|
||||
.post("$API_HOST/api/login") {
|
||||
.post("${Config.getApiBaseUrl()}/login") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request.also { ru.fromchat.core.Logger.d("ApiClient", "Login request: $it") })
|
||||
}
|
||||
@@ -73,7 +71,7 @@ object ApiClient {
|
||||
|
||||
suspend fun register(request: RegisterRequest) =
|
||||
http
|
||||
.post("$API_HOST/api/register") {
|
||||
.post("${Config.getApiBaseUrl()}/register") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
@@ -81,7 +79,7 @@ object ApiClient {
|
||||
|
||||
suspend fun getMessages() =
|
||||
http
|
||||
.get("$API_HOST/api/get_messages") {
|
||||
.get("${Config.getApiBaseUrl()}/get_messages") {
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
.failOnError()
|
||||
@@ -89,7 +87,7 @@ object ApiClient {
|
||||
|
||||
suspend fun send(message: String) =
|
||||
http
|
||||
.post("$API_HOST/api/send_message") {
|
||||
.post("${Config.getApiBaseUrl()}/send_message") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
setBody(SendMessageRequest(message))
|
||||
@@ -99,7 +97,7 @@ object ApiClient {
|
||||
|
||||
suspend fun editMessage(messageId: Int, content: String) =
|
||||
http
|
||||
.put("$API_HOST/api/edit_message/$messageId") {
|
||||
.put("${Config.getApiBaseUrl()}/edit_message/$messageId") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
setBody(EditMessageRequest(content))
|
||||
@@ -109,9 +107,20 @@ object ApiClient {
|
||||
|
||||
suspend fun deleteMessage(messageId: Int) =
|
||||
http
|
||||
.delete("$API_HOST/api/delete_message/$messageId") {
|
||||
.delete("${Config.getApiBaseUrl()}/delete_message/$messageId") {
|
||||
contentType(ContentType.Application.Json)
|
||||
bearerAuth(token!!)
|
||||
}
|
||||
.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.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.fromchat.WS_API_HOST
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.core.Logger
|
||||
import kotlin.concurrent.Volatile
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
@@ -55,10 +55,12 @@ object WebSocketManager {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
try {
|
||||
val wsUrl = Config.getWebSocketUrl()
|
||||
Logger.d("WebSocketManager", "Connecting to: $wsUrl")
|
||||
ApiClient.http.webSocket(
|
||||
method = HttpMethod.Get,
|
||||
request = {
|
||||
url("$WS_API_HOST/api/chat/ws")
|
||||
url(wsUrl)
|
||||
}
|
||||
) {
|
||||
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,82 +3,145 @@ 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.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.fromchat.api.WebSocketManager
|
||||
import ru.fromchat.core.config.Config
|
||||
import ru.fromchat.ui.auth.LoginScreen
|
||||
import ru.fromchat.ui.auth.RegisterScreen
|
||||
import ru.fromchat.ui.main.MainScreen
|
||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||
|
||||
val LocalNavController = compositionLocalOf<NavController> { error("") }
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
var startDestination by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
// Initialize config and check server configuration on startup
|
||||
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 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
|
||||
)
|
||||
if (startDestination == null) {
|
||||
Box(
|
||||
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
) {
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
navController.navigate("chat")
|
||||
navController.popBackStack()
|
||||
} else {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination!!,
|
||||
enterTransition = {
|
||||
slideIntoContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") }
|
||||
)
|
||||
}
|
||||
exitTransition = {
|
||||
slideOutOfContainer(
|
||||
Start,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popEnterTransition = {
|
||||
slideIntoContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
},
|
||||
popExitTransition = {
|
||||
slideOutOfContainer(
|
||||
End,
|
||||
animationSpec = animationSpec
|
||||
)
|
||||
}
|
||||
) {
|
||||
composable("serverConfig") {
|
||||
ServerConfigScreen()
|
||||
}
|
||||
|
||||
composable("login") {
|
||||
LoginScreen(
|
||||
onLoginSuccess = {
|
||||
// Ensure WebSocket is connected after login
|
||||
WebSocketManager.connect()
|
||||
navController.navigate("chat") {
|
||||
popUpTo("login") { inclusive = true }
|
||||
}
|
||||
},
|
||||
onNavigateToRegister = { navController.navigate("register") }
|
||||
)
|
||||
}
|
||||
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.navigate("login") }
|
||||
)
|
||||
}
|
||||
composable("register") {
|
||||
RegisterScreen(
|
||||
onRegistered = { navController.navigate("login") }
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
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
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import ru.fromchat.core.Settings
|
||||
|
||||
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),
|
||||
enum class Theme {
|
||||
AsSystem,
|
||||
Light,
|
||||
Dark
|
||||
}
|
||||
|
||||
var dynamicThemeEnabled by mutableStateOf(
|
||||
runCatching { Settings.materialYou }.getOrNull() == true
|
||||
)
|
||||
|
||||
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),
|
||||
var theme by mutableStateOf(
|
||||
runCatching { Settings.theme }.getOrNull() ?: Theme.AsSystem
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean): ColorScheme
|
||||
|
||||
@Composable
|
||||
fun FromChatTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = false, // Disabled for multiplatform compatibility
|
||||
darkTheme: Boolean = when (theme) {
|
||||
Theme.AsSystem -> isSystemInDarkTheme()
|
||||
Theme.Light -> false
|
||||
Theme.Dark -> true
|
||||
},
|
||||
dynamicColor: Boolean = dynamicThemeEnabled,
|
||||
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(
|
||||
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.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.Mail
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
@@ -28,12 +30,15 @@ import ru.fromchat.chats
|
||||
import ru.fromchat.coming_soon
|
||||
import ru.fromchat.contacts
|
||||
import ru.fromchat.dms
|
||||
import ru.fromchat.settings
|
||||
import ru.fromchat.ui.LocalNavController
|
||||
import ru.fromchat.utils.exclude
|
||||
|
||||
@Suppress("AssignedValueIsNeverRead")
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
var selectedTab by remember { mutableStateOf("chats") }
|
||||
fun MainScreen(onLogout: () -> Unit = {}) {
|
||||
var selectedTab by rememberSaveable { mutableStateOf("chats") }
|
||||
val navController = LocalNavController.current
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
@@ -56,6 +61,12 @@ fun MainScreen() {
|
||||
label = { Text(stringResource(Res.string.dms)) },
|
||||
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),
|
||||
@@ -74,6 +85,9 @@ fun MainScreen() {
|
||||
"dms" -> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user