13 Commits

39 changed files with 883 additions and 1087 deletions
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
+16 -11
View File
@@ -17,21 +17,22 @@ kotlin {
}
}
// 1. Описываем задачу копирования с созданием правильной структуры
val fixComposeResourcesStructure = tasks.register<Copy>("fixComposeResourcesStructure") {
val sharedProject = rootProject.project(":app:shared")
// Откуда берем (исходные сгенерированные файлы)
val sourceDir = sharedProject.layout.buildDirectory
.dir("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources")
from(
sharedProject
.layout
.buildDirectory
.dir("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources")
)
// Куда кладем (создаем промежуточную папку с ПРАВИЛЬНЫМ путем)
val outputDir = layout.buildDirectory.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat")
into(
layout
.buildDirectory
.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat")
)
from(sourceDir)
into(outputDir)
// Убеждаемся, что генерация в shared уже прошла
dependsOn(sharedProject.tasks.matching { it.name.contains("prepareComposeResources", ignoreCase = true) })
dependsOn(sharedProject.tasks.matching { it.name.contains("copyNonXmlValueResources", ignoreCase = true) })
}
@@ -55,7 +56,7 @@ android {
}
storeFile = file("keys/release.jks")
keyAlias = "release"
keyAlias = "key0"
storePassword = keystoreProperties["storePassword"].toString()
keyPassword = keystoreProperties["keyPassword"].toString()
enableV3Signing = true
@@ -97,6 +98,10 @@ tasks.withType<MergeSourceSetFolders>().configureEach {
dependsOn(fixComposeResourcesStructure)
}
tasks.matching { it.name.contains("lintVital", ignoreCase = true) }.configureEach {
dependsOn(fixComposeResourcesStructure)
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict />
</plist>
+7 -7
View File
@@ -17,11 +17,16 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>0.2.4</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>024</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
@@ -46,10 +51,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.android.kotlin.multiplatform.library)
kotlin("plugin.serialization") version "1.9.22"
alias(libs.plugins.kotlin.serialization)
}
kotlin {
@@ -67,4 +67,6 @@
<string name="typing_single">%1$s is typing…</string>
<string name="typing_two">%1$s and %2$s are typing…</string>
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
<string name="more">More</string>
</resources>
@@ -1,18 +0,0 @@
package ru.fromchat
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.format.char
val DATETIME_FORMAT = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
@@ -60,9 +60,7 @@ data class Message(
val reply_to: Message? = null,
val client_message_id: String? = null,
val reactions: List<ReactionData>? = null
) {
val utcTimestamp = "${timestamp}Z"
}
)
@Serializable
data class SendMessageRequest(
@@ -35,9 +35,4 @@ data class ReactionUser(
data class TypingUpdateData(
val userId: Int,
val username: String
)
@Serializable
data class WebSocketAuthMessage(
val token: String
)
@@ -8,19 +8,75 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import ru.fromchat.ui.Theme
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
object Settings {
private const val MATERIAL_YOU_KEY = "materialYou"
private const val THEME_KEY = "theme"
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
private val settings = Settings()
private fun runIO(block: suspend CoroutineScope.() -> Unit) {
CoroutineScope(Dispatchers.IO).launch(block = block)
}
private fun serverConfigNotInitialized(): Nothing
= throw IllegalStateException("Server config not initialized")
var materialYou: Boolean
get() = runBlocking { settings.getBoolean("materialYou", true) }
set(value) = runIO { settings.putBoolean("materialYou", value) }
get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
set(value) = runIO { settings.putBoolean(MATERIAL_YOU_KEY, value) }
var theme: Theme
get() = runBlocking { Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt("theme", value.ordinal) }
get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt(THEME_KEY, value.ordinal) }
var serverUrl: String
get() = runBlocking {
settings.getString(SERVER_URL_KEY).ifEmpty {
serverConfigNotInitialized()
}
}
set(value) = runIO { settings.putString(SERVER_URL_KEY, value) }
var httpsEnabled: Boolean
get() = runBlocking {
if (settings.contains(HTTPS_ENABLED_KEY))
settings.getBoolean(HTTPS_ENABLED_KEY, true)
else serverConfigNotInitialized()
}
set(value) = runIO { settings.putBoolean(HTTPS_ENABLED_KEY, value) }
val hasServerConfig get() = try {
serverUrl
httpsEnabled
true
} catch (_: IllegalStateException) {
false
}
var serverConfig: ServerConfigData
get() {
if (!hasServerConfig) {
serverUrl = "fromchat.ru"
httpsEnabled = true
return ServerConfigData("fromchat.ru", true)
}
return ServerConfigData(serverUrl, httpsEnabled)
}
set(value) {
serverUrl = value.serverUrl
httpsEnabled = value.httpsEnabled
}
}
@@ -1,10 +1,10 @@
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
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.Settings
/**
* Application configuration
@@ -19,15 +19,15 @@ object Config {
/**
* Initialize configuration by loading from storage
*/
suspend fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig()
fun initialize() {
_serverConfig.value = Settings.serverConfig
}
/**
* Update server configuration
*/
suspend fun updateServerConfig(config: ServerConfigData) {
ServerConfigStorage.saveConfig(config)
fun updateServerConfig(config: ServerConfigData) {
Settings.serverConfig = config
_serverConfig.value = config
}
@@ -42,9 +42,4 @@ object Config {
*/
val webSocketUrl
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
}
@@ -3,19 +3,11 @@ 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.DisposableEffect
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.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -25,8 +17,6 @@ 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
@@ -39,30 +29,9 @@ 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) {
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"
}
}
runCatching {
Config.initialize()
}
}
@@ -96,82 +65,73 @@ fun App() {
CompositionLocalProvider(
LocalNavController provides navController
) {
if (startDestination == null) {
Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
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("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
}
}
}
}
@@ -1,21 +1,31 @@
package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.wrapContentSize
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.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -33,24 +43,73 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest
import ru.fromchat.change_server
import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields
import ru.fromchat.login
import ru.fromchat.login_d
import ru.fromchat.more
import ru.fromchat.password
import ru.fromchat.register_button
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader
import ru.fromchat.username
import ru.fromchat.welcome
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit
) {
val errorUnexpected = stringResource(Res.string.error_unexpected)
val navController = LocalNavController.current
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
Scaffold(
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(
actions = {
var expanded by remember { mutableStateOf(false) }
Box(Modifier.wrapContentSize(Alignment.TopEnd)) {
IconButton(
onClick = {
expanded = true
}
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(Res.string.more)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false } // Закрыть при нажатии вне меню
) {
DropdownMenuItem(
text = {
Text(stringResource(Res.string.change_server))
},
onClick = {
expanded = false
navController.navigate("serverConfig")
},
leadingIcon = {
Icon(
Icons.Filled.Storage,
contentDescription = null
)
}
)
}
}
},
title = {}
)
}
) { innerPadding ->
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) }
@@ -1,5 +1,6 @@
package ru.fromchat.ui.chat
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import kotlin.math.abs
@@ -7,90 +8,44 @@ import kotlin.math.abs
/**
* Get gradient brush for own messages
*/
fun getMessageGradient(isDark: Boolean): Brush {
return if (isDark) {
Brush.linearGradient(
colors = listOf(
Color(0xFF9333EA),
Color(0xFF6366F1),
Color(0xFF2F68C5)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
fun getMessageGradient(isDark: Boolean) = Brush.linearGradient(
colors = if (isDark) {
listOf(
Color(0xFF9333EA),
Color(0xFF6366F1),
Color(0xFF2F68C5)
)
} else {
Brush.linearGradient(
colors = listOf(
Color(0xFFB794F6),
Color(0xFF818CF8),
Color(0xFF60A5FA)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
listOf(
Color(0xFFB794F6),
Color(0xFF818CF8),
Color(0xFF60A5FA)
)
}
}
},
start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
/**
* Get background gradient brushes for chat background
* Get gradient brush for own messages
*/
fun getBackgroundGradients(isDark: Boolean): List<Brush> {
return if (isDark) {
fun getReplyMessageGradient(isDark: Boolean) = Brush.linearGradient(
colors = if (isDark) {
listOf(
Brush.radialGradient(
colors = listOf(
Color(0x26DBA1F9),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x26F3B7BE),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x1AD0C1DA),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
radius = 500f
)
Color(0xFF5f11a6),
Color(0xFF1418da),
Color(0xFF1b3d73)
)
} else {
listOf(
Brush.radialGradient(
colors = listOf(
Color(0x15B794F6),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.2f, 0.8f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x15F3B7BE),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.8f, 0.2f),
radius = 500f
),
Brush.radialGradient(
colors = listOf(
Color(0x0DD0C1DA),
Color.Transparent
),
center = androidx.compose.ui.geometry.Offset(0.4f, 0.4f),
radius = 500f
)
Color(0xFF7836ee),
Color(0xFF2034f3),
Color(0xFF076eed)
)
}
}
},
start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
/**
* Generate a consistent gradient from a name for avatar fallback
@@ -102,21 +57,21 @@ fun generateGradientFromName(name: String): Brush {
val b = abs((hash / 65536) % 256)
// Create two colors based on hash for gradient
val color1 = Color(
red = (r + 100).coerceIn(0, 255) / 255f,
green = (g + 100).coerceIn(0, 255) / 255f,
blue = (b + 100).coerceIn(0, 255) / 255f
)
val color2 = Color(
red = (r + 50).coerceIn(0, 255) / 255f,
green = (g + 50).coerceIn(0, 255) / 255f,
blue = (b + 50).coerceIn(0, 255) / 255f
)
return Brush.linearGradient(
colors = listOf(color1, color2),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(100f, 100f)
colors = listOf(
Color(
red = (r + 100).coerceIn(0, 255) / 255f,
green = (g + 100).coerceIn(0, 255) / 255f,
blue = (b + 100).coerceIn(0, 255) / 255f
),
Color(
red = (r + 50).coerceIn(0, 255) / 255f,
green = (g + 50).coerceIn(0, 255) / 255f,
blue = (b + 50).coerceIn(0, 255) / 255f
)
),
start = Offset(0f, 0f),
end = Offset(100f, 100f)
)
}
@@ -1,7 +1,12 @@
package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
@@ -20,15 +25,16 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Reply
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -41,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
@@ -56,6 +63,78 @@ import ru.fromchat.Res
import ru.fromchat.api.Message
import ru.fromchat.message_placeholder
@Composable
private fun <T> AnimatedPreviewBar(
state: T?,
content: @Composable (T) -> Unit
) {
AnimatedVisibility(
visible = state != null,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
var lastState by remember { mutableStateOf(state) }
LaunchedEffect(state) {
if (state != null) {
lastState = state
}
}
if (state != null || lastState != null) {
content(state ?: lastState!!)
}
}
}
@Composable
private fun PreviewBar(
icon: ImageVector,
title: String,
subtitle: String,
onClose: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 6.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalHazeMaterialsApi::class)
@Composable
fun ChatInput(
@@ -77,6 +156,7 @@ fun ChatInput(
if (text.isNotBlank()) {
typingJob?.cancel()
typingHandler.sendTyping()
@Suppress("AssignedValueIsNeverRead")
typingJob = scope.launch {
delay(3000) // 3 seconds
typingHandler.stopTyping()
@@ -87,204 +167,106 @@ fun ChatInput(
}
}
Column(
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) {
// Reply preview
replyTo?.let { reply ->
ReplyPreviewBar(
replyTo = reply,
onClose = onClearReply,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
val shape = RoundedCornerShape(24.dp)
// Edit preview
editingMessage?.let { edit ->
EditPreviewBar(
message = edit,
onClose = onClearEdit,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
// Input field with blur
Box(
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(horizontal = 8.dp, vertical = 8.dp)
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val shape = RoundedCornerShape(24.dp)
AnimatedPreviewBar(replyTo) { replyTo ->
PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply,
title = "Replying to ${replyTo.username}",
subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
onClose = { onClearReply() }
)
}
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.weight(1f)
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
AnimatedPreviewBar(editingMessage) { message ->
PreviewBar(
icon = Icons.Filled.Edit,
title = "Editing message",
subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "",
onClose = { onClearEdit() }
)
}
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier
.fillMaxWidth()
.animateContentSize(),
placeholder = {
Text(
text = stringResource(Res.string.message_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = text.isNotBlank(),
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
placeholder = {
Text(
text = stringResource(Res.string.message_placeholder),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
)
},
shape = shape,
maxLines = 5,
singleLine = false,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent
),
trailingIcon = {
val offset = with(LocalDensity.current) { 20.dp.toPx().toInt() }
AnimatedVisibility(
visible = text.isNotBlank(),
enter = slideInHorizontally(
initialOffsetX = { it + offset },
animationSpec = tween(durationMillis = 300)
),
exit = slideOutHorizontally(
targetOffsetX = { it + offset },
animationSpec = tween(durationMillis = 200)
)
) {
Box(Modifier.padding(end = 5.dp)) {
FilledIconButton(
onClick = {
onSend(text.trim())
onTextChange("")
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
) {
Box(Modifier.padding(end = 5.dp)) {
FilledIconButton(
onClick = {
onSend(text.trim())
onTextChange("")
typingHandler.stopTyping()
},
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
}
}
)
}
}
)
}
}
}
@Composable
private fun ReplyPreviewBar(
replyTo: Message,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier, // hazeEffect moved to ChatInput where it's called
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Replying to ${replyTo.username}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun EditPreviewBar(
message: Message,
onClose: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier, // hazeEffect moved to ChatInput where it's called
shape = RoundedCornerShape(8.dp),
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Editing message",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = message.content.take(50) + if (message.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@@ -188,13 +188,12 @@ fun ChatScreen(
topBar = {
TopAppBar(
title = {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Column(Modifier.fillMaxWidth()) {
Text(
text = panelState.title,
style = MaterialTheme.typography.titleLarge
)
AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = {
@@ -207,9 +206,6 @@ fun ChatScreen(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else {
// Empty space to maintain height
Box(modifier = Modifier.height(0.dp))
}
}
}
@@ -279,7 +275,10 @@ fun ChatScreen(
replyTo = replyTo,
editingMessage = editingMessage,
onClearReply = { replyTo = null },
onClearEdit = { editingMessage = null },
onClearEdit = {
editingMessage = null
inputText = ""
},
hazeState = hazeState
)
}
@@ -308,12 +307,13 @@ fun ChatScreen(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom)
verticalArrangement = Arrangement.spacedBy(4.dp, alignment = Alignment.Bottom),
reverseLayout = true
) {
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
items(
items = panelState.messages,
items = panelState.messages.reversed(),
key = { it.id }
) { message ->
val isAuthor = message.user_id == currentUserId
@@ -350,18 +350,22 @@ fun ChatScreen(
}
}
item { Spacer(Modifier.height(innerPadding.calculateBottomPadding())) } // Spacer for chat input
item { Spacer(Modifier.height(innerPadding.calculateTopPadding())) } // Spacer for TopAppBar
}
}
// Context menu
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu(
state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message ->
replyTo = message
editingMessage = null
if (editingMessage != null) {
editingMessage = null
inputText = ""
}
},
onEdit = { message ->
editingMessage = message
@@ -12,9 +12,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
@@ -27,12 +30,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message
@@ -92,18 +95,11 @@ fun MessageItem(
.widthIn(max = 280.dp),
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
) {
// Reply preview
message.reply_to?.let { replyTo ->
ReplyPreview(
replyTo = replyTo,
modifier = Modifier.padding(bottom = 4.dp)
)
}
// Message bubble
val isDark = isSystemInDarkTheme()
Box(
modifier = Modifier
.width(IntrinsicSize.Min)
.clip(
RoundedCornerShape(
topStart = 20.dp,
@@ -112,35 +108,27 @@ fun MessageItem(
bottomEnd = if (isAuthor) 8.dp else 20.dp
)
)
.then(
if (isAuthor) {
Modifier.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = 20.dp,
bottomEnd = 8.dp
),
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
)
} else {
Modifier
}
)
.background(
brush = if (isAuthor) {
getMessageGradient(isDark)
} else {
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest,
MaterialTheme.colorScheme.surfaceContainerHighest
.conditional(
isAuthor,
`if` = {
it
.shadow(
elevation = 8.dp,
shape = RoundedCornerShape(
topStart = 20.dp,
topEnd = 20.dp,
bottomStart = 20.dp,
bottomEnd = 8.dp
),
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
)
)
.background(getMessageGradient(isDark))
},
`else` = {
background(MaterialTheme.colorScheme.surfaceContainerHighest)
}
)
.padding(horizontal = 12.dp, vertical = 8.dp)
.padding(top = 6.dp)
) {
Column {
// Username inside bubble (for received messages)
@@ -150,10 +138,59 @@ fun MessageItem(
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp)
)
}
// Reply preview
message.reply_to?.let { replyTo ->
Box(
Modifier.padding(bottom = 4.dp, start = 6.dp, end = 6.dp)
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.fillMaxWidth()
.height(IntrinsicSize.Min)
.conditional(
isAuthor,
`if` = {
background(getReplyMessageGradient(isDark))
},
`else` = {
background(MaterialTheme.colorScheme.surfaceVariant)
}
)
) {
Box(
Modifier
.background(MaterialTheme.colorScheme.primary)
.width(3.dp)
.fillMaxHeight()
)
Column(
Modifier.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
}
// Message content
Text(
text = message.content,
@@ -162,12 +199,14 @@ fun MessageItem(
Color.White
} else {
MaterialTheme.colorScheme.onSurface
}
},
modifier = Modifier.padding(horizontal = 12.dp)
)
// Timestamp and edited indicator
Row(
modifier = Modifier.padding(top = 4.dp),
modifier = Modifier
.padding(start = 12.dp, end = 12.dp, top = 4.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
@@ -202,45 +241,17 @@ fun MessageItem(
}
}
@Composable
private fun ReplyPreview(
replyTo: Message,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 8.dp, vertical = 6.dp)
) {
Column {
Text(
text = replyTo.username,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
fontSize = 11.sp
)
Text(
text = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
maxLines = 1
)
}
}
}
@ExperimentalTime
private fun formatTime(timestamp: String): String {
return try {
val instant = Instant.parse(timestamp)
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
val hour = localDateTime.hour.toString().padStart(2, '0')
val minute = localDateTime.minute.toString().padStart(2, '0')
"$hour:$minute"
} catch (e: Exception) {
Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let {
"${
it.hour.toString().padStart(2, '0')
}:${
it.minute.toString().padStart(2, '0')
}"
}
} catch (_: Exception) {
// Fallback: try parsing without timezone if it fails
try {
val parts = timestamp.split("T")
@@ -254,9 +265,8 @@ private fun formatTime(timestamp: String): String {
} else {
""
}
} catch (e2: Exception) {
} catch (_: Exception) {
""
}
}
}
}
@@ -29,7 +29,7 @@ class PublicChatPanel(
scope.launch {
typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) }
updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
}
}
}
@@ -58,7 +58,7 @@ class PublicChatPanel(
}
setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true
} catch (e: Exception) {
} catch (_: Exception) {
// Handle error
} finally {
setLoading(false)
@@ -84,7 +84,7 @@ class PublicChatPanel(
}
}
setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (e: Exception) {
} catch (_: Exception) {
// Handle error
} finally {
setLoadingMore(false)
@@ -4,9 +4,12 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -63,67 +65,54 @@ private fun formatTypingText(typingUsers: List<String>): String {
@Composable
private fun TypingDots() {
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
val dot1Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
val duration = 1000
val initialScale = 1f
val targetScale = 1.3f
// Функция-хелпер для создания анимации с задержкой
@Composable
fun animateDotScale(delay: Int) = infiniteTransition.animateFloat(
initialValue = initialScale,
targetValue = initialScale, // Возвращаемся в начало
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 0
1f at 200
0.3f at 400
durationMillis = duration
initialScale at delay // Начало подъема
targetScale at delay + 200 // Пик
initialScale at delay + 400 // Возврат
initialScale at duration // Удержание до конца цикла
}
),
label = "dot1"
)
val dot2Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 200
1f at 400
0.3f at 600
}
),
label = "dot2"
)
val dot3Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 400
1f at 600
0.3f at 800
}
),
label = "dot3"
label = "dot_scale"
)
Row(verticalAlignment = Alignment.CenterVertically) {
Dot(alpha = dot1Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot2Alpha)
Spacer(modifier = Modifier.width(2.dp))
Dot(alpha = dot3Alpha)
val dot1Scale by animateDotScale(delay = 0)
val dot2Scale by animateDotScale(delay = 200)
val dot3Scale by animateDotScale(delay = 400)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp) // Вместо Spacer
) {
Dot(scale = dot1Scale, maxScale = targetScale)
Dot(scale = dot2Scale, maxScale = targetScale)
Dot(scale = dot3Scale, maxScale = targetScale)
}
}
@Composable
private fun Dot(alpha: Float) {
Surface(
modifier = Modifier
.width(4.dp)
.height(4.dp)
.alpha(alpha),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {}
}
@Suppress("SameParameterValue")
private fun Dot(maxScale: Float, scale: Float) {
Box(
modifier = Modifier.size(4.dp * maxScale),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(4.dp * scale)
.height(4.dp * scale),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {}
}
}
@@ -188,20 +188,6 @@ fun SettingsTab(
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")
}
},
@@ -40,19 +40,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack
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.ServerConfigData
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
@@ -63,16 +62,16 @@ fun ServerConfigScreen() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available
var serverUrl by remember { mutableStateOf("") }
var httpsEnabled by remember { mutableStateOf(false) }
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
Config.serverConfig.value?.let {
serverUrl = it.serverUrl
httpsEnabled = it.httpsEnabled
}
}
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -82,11 +81,13 @@ fun ServerConfigScreen() {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = navController::navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
if (navController.currentBackStackEntry != null) {
IconButton(onClick = navController::navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
}
},
scrollBehavior = scrollBehavior
@@ -122,7 +123,7 @@ fun ServerConfigScreen() {
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text(stringResource(Res.string.server_url_label)) },
placeholder = { Text(stringResource(Res.string.server_url_hint)) },
placeholder = { Text("fromchat.ru") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
@@ -147,16 +148,13 @@ fun ServerConfigScreen() {
onClick = {
isLoading = true
scope.launch {
val config = ServerConfigData(serverUrl, httpsEnabled)
Config.updateServerConfig(config)
Config.updateServerConfig(ServerConfigData(serverUrl, httpsEnabled))
// Ensure we're logged out when server config changes
try {
runCatching {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors
}
// Clear API client state
@@ -1,7 +1,8 @@
package ru.fromchat
import ru.fromchat.ui.App
import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App
@Suppress("unused")
fun MainViewController() = ComposeUIViewController { App() }
+5 -3
View File
@@ -17,7 +17,8 @@ gson = "2.13.2"
kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2"
kotlinxSerializationJson = "1.9.0"
serialization = "2.3.0"
serialization-json = "1.9.0"
material = "1.13.0"
activityKtx = "1.12.2"
navigationCompose = "2.9.1"
@@ -45,7 +46,7 @@ kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", versio
biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
@@ -75,4 +76,5 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version = "2.3.0" }
android-library = { id = "com.android.library", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
+223 -35
View File
@@ -1,46 +1,234 @@
#!/bin/bash
# 1. Setup
# --- Настройка и окружение ---
set -e
cd "$(dirname "$0")"
# 2. Variables
TAG=$(git describe --tags --abbrev=0)
# --- Обработка аргументов ---
IS_PRERELEASE=false
show_help() {
echo "Usage: $0 [arguments]"
echo ""
echo "Arguments:"
echo " --pre Enable pre-release"
echo " --help Show this message"
}
for arg in "$@"; do
case $arg in
--pre)
IS_PRERELEASE=true
shift
;;
--help)
show_help
exit 0
;;
*)
echo -e "\033[0;31mError: Unknown argument '$arg'\033[0m"
show_help
exit 1
;;
esac
done
# --- Конфигурация цветов и оформления ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# --- Функции логирования ---
info() { echo -e "${BLUE}${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; }
warning() { echo -e "${YELLOW}${NC} $1"; }
error() { echo -e "${RED}${NC} $1"; }
step() { echo -e "\n${CYAN}${BOLD}${NC} ${BOLD}$1${NC}"; }
substep() { echo -e " ${GREEN}${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
# --- Настройка памяти для предотвращения OutOfMemory ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
# --- Переменные Git / Версии ---
step "Metadata collection"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1")
# shellcheck disable=SC2207
TAGS=($(git --no-pager tag --sort -v:refname | xargs))
PREVTAG=${TAGS[1]}
ASSET_PATH=./app/build/outputs/apk/release/app-release.apk
DISPLAY_NAME=FromChat.$TAG.apk
PREVTAG=${TAGS[1]:-$TAG}
# 3. Build release variant
./gradlew assembleRelease
substep "Current tag: ${YELLOW}$TAG${NC}"
substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
# 4. Move locally
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android ---
build_android() {
step "Building Android Release"
if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then
mkdir -p releases
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "releases/$DISPLAY_NAME"
ANDROID_ASSET="releases/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else
error "Android APK not found after build"
exit 1
fi
else
error "Android build failed"
exit 1
fi
}
# --- Сборка iOS ---
build_ios() {
step "Building iOS Release"
if [[ "$OSTYPE" != "darwin"* ]]; then
warning "iOS build requires macOS. Skipping."
return
fi
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
# shellcheck disable=SC2155
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
substep "Setting iOS version to $BUILD_NUMBER..."
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${TAG#v}" "$PLIST_PATH" || true
fi
substep "Xcode Archiving (Unsigned)..."
mkdir -p build/ios
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-derivedDataPath build/ios \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \
build > build/ios/build.log 2>&1; then
success "Xcode build successful."
else
error "Xcode build failed. Check build/ios/build.log"
exit 1
fi
substep "Packaging IPA for TrollStore..."
mkdir -p build/ios/Payload
APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then
cp -r "$APP_PATH" build/ios/Payload/
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null
rm -rf build/ios/Payload
IOS_ASSET="releases/$IPA_NAME"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else
error "Could not find .app bundle"
exit 1
fi
}
# --- Подготовка описания ---
prepare_description() {
DESC_FILE=".release_desc.md"
echo -e "<!--\nEnter the release description. Leave empty for no description.\n-->" > "$DESC_FILE"
# Открываем nano. Пользователь должен отредактировать файл.
nano "$DESC_FILE"
# Удаляем комментарии <!-- ... --> и лишние пробелы/пустые строки
# Используем perl для многострочного удаления комментариев
CLEAN_DESC=$(perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
rm -f "$DESC_FILE"
if [[ -n "$CLEAN_DESC" ]]; then
echo "$CLEAN_DESC" > .final_notes.md
NOTES_ARG=("-F" "./.final_notes.md")
else
NOTES_ARG=("--generate-notes")
fi
}
# --- Публикация в GitHub ---
publish_github() {
step "GitHub Deployment"
if ! command -v gh &> /dev/null; then
warning "GitHub CLI (gh) not found. Skipping upload."
return
fi
substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true
prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease"
fi
ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
if [ ${#ASSETS[@]} -eq 0 ]; then
error "No assets found to upload."
return
fi
# Подготавливаем описание перед созданием
prepare_description
if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag"
# Если есть кастомные ноты, обновляем их
if [[ -f .final_notes.md ]]; then
gh release edit "$TAG" -F ./.final_notes.md
fi
gh release upload "$TAG" "${ASSETS[@]}" --clobber
else
substep "Creating new release ${YELLOW}$TAG${NC}..."
# gh release create принимает либо --generate-notes, либо --notes-file
gh release create "$TAG" \
"${NOTES_ARG[@]}" \
--notes-start-tag "${PREVTAG:-$TAG}" \
$prerelease_flag \
"${ASSETS[@]}"
fi
rm -f .final_notes.md
success "Assets and notes uploaded to GitHub Release"
}
# --- Основной процесс ---
mkdir -p releases
mv "$ASSET_PATH" "releases/$DISPLAY_NAME"
ASSET_PATH=./releases/$DISPLAY_NAME
build_android
build_ios
publish_github
# 6. Release to GitHub
prerelease_opt=$([[ "$TAG" != v*-pre* ]] || echo "--prerelease")
new_release() {
gh release create \
"$TAG" \
--generate-notes \
--notes-start-tag "$PREVTAG" \
"$prerelease_opt" \
"$ASSET_PATH"
}
edit_existing() {
gh release edit \
"$TAG" \
"$prerelease_opt"
gh release upload \
"$TAG" \
--clobber \
"$ASSET_PATH"
}
git push --tags
new_release || edit_existing
echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
@@ -720,6 +720,7 @@ val ActivityInfo.launchIntent get() = Intent().apply {
flags = FLAG_ACTIVITY_NEW_TASK
}
@SuppressLint("QueryPermissionsNeeded")
@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
fun ActivityInfo.isLauncher(context: Context): Boolean {
with (context) {
@@ -93,4 +93,15 @@ class AndroidSettings(): Settings {
preferences.remove(stringSetPreferencesKey(key))
}
}
override suspend fun contains(key: String): Boolean {
return dataStore.data.map { preferences ->
preferences.contains(stringPreferencesKey(key)) ||
preferences.contains(intPreferencesKey(key)) ||
preferences.contains(longPreferencesKey(key)) ||
preferences.contains(floatPreferencesKey(key)) ||
preferences.contains(booleanPreferencesKey(key)) ||
preferences.contains(stringSetPreferencesKey(key))
}.first()
}
}
@@ -1,128 +0,0 @@
package com.pr0gramm3r101.utils.settings
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class AndroidAsyncSettings : AsyncSettings {
private val dataStore: DataStore<Preferences>
get() = DataStoreSingleton.dataStore
override suspend fun putString(key: String, value: String) {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(key)] = value
}
}
override suspend fun getString(key: String, default: String): String {
return dataStore.data.map { preferences ->
preferences[stringPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putInt(key: String, value: Int) {
dataStore.edit { preferences ->
preferences[intPreferencesKey(key)] = value
}
}
override suspend fun getInt(key: String, default: Int): Int {
return dataStore.data.map { preferences ->
preferences[intPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putLong(key: String, value: Long) {
dataStore.edit { preferences ->
preferences[longPreferencesKey(key)] = value
}
}
override suspend fun getLong(key: String, default: Long): Long {
return dataStore.data.map { preferences ->
preferences[longPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putFloat(key: String, value: Float) {
dataStore.edit { preferences ->
preferences[floatPreferencesKey(key)] = value
}
}
override suspend fun getFloat(key: String, default: Float): Float {
return dataStore.data.map { preferences ->
preferences[floatPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putBoolean(key: String, value: Boolean) {
dataStore.edit { preferences ->
preferences[booleanPreferencesKey(key)] = value
}
}
override suspend fun getBoolean(key: String, default: Boolean): Boolean {
return dataStore.data.map { preferences ->
preferences[booleanPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putStringSet(key: String, value: Set<String>) {
dataStore.edit { preferences ->
preferences[stringSetPreferencesKey(key)] = value
}
}
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> {
return dataStore.data.map { preferences ->
preferences[stringSetPreferencesKey(key)] ?: default
}.first()
}
override suspend fun putStringList(key: String, value: List<String>) {
putStringSet(key, value.toSet())
}
override suspend fun getStringList(key: String, default: List<String>): List<String> {
return getStringSet(key, default.toSet()).toList()
}
override suspend fun remove(key: String) {
dataStore.edit { preferences ->
preferences.remove(stringPreferencesKey(key))
preferences.remove(intPreferencesKey(key))
preferences.remove(longPreferencesKey(key))
preferences.remove(floatPreferencesKey(key))
preferences.remove(booleanPreferencesKey(key))
preferences.remove(stringSetPreferencesKey(key))
}
}
override suspend fun contains(key: String): Boolean {
return dataStore.data.map { preferences ->
preferences.contains(stringPreferencesKey(key)) ||
preferences.contains(intPreferencesKey(key)) ||
preferences.contains(longPreferencesKey(key)) ||
preferences.contains(floatPreferencesKey(key)) ||
preferences.contains(booleanPreferencesKey(key)) ||
preferences.contains(stringSetPreferencesKey(key))
}.first()
}
override suspend fun clear() {
dataStore.edit { preferences ->
preferences.clear()
}
}
}
actual val asyncSettings: AsyncSettings = AndroidAsyncSettings()
@@ -1,3 +1,5 @@
package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = AndroidSettings()
actual val settings: Settings get() = AndroidSettings()
actual val secureSettings: Settings
get() = TODO("Not yet implemented")
@@ -1,5 +1,6 @@
package com.pr0gramm3r101.utils
import androidx.compose.ui.Modifier
import androidx.navigation.NavController
/**
@@ -21,18 +22,22 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
val currentRoute = currentBackStackEntry?.destination?.route
// Navigate to the new route, removing the current screen as well
if (currentRoute != null && currentRoute != route) {
navigate(route) {
navigate(route) {
if (currentRoute != null && currentRoute != route) {
popUpTo(currentRoute) {
inclusive = true
}
this.launchSingleTop = launchSingleTop
}
} else {
// Fallback: just navigate if current route is same or null
navigate(route) {
this.launchSingleTop = launchSingleTop
}
this.launchSingleTop = launchSingleTop
}
}
inline fun Modifier.conditional(
condition: Boolean,
`else`: Modifier.(Modifier) -> Modifier = { Modifier },
`if`: Modifier.(Modifier) -> Modifier
) = if (condition) {
this + `if`(this)
} else {
this + `else`(this)
}
@@ -1,37 +0,0 @@
package com.pr0gramm3r101.utils.settings
/**
* Async version of Settings API using suspend functions
*/
interface AsyncSettings {
suspend fun putString(key: String, value: String)
suspend fun getString(key: String, default: String = ""): String
suspend fun putInt(key: String, value: Int)
suspend fun getInt(key: String, default: Int = 0): Int
suspend fun putLong(key: String, value: Long)
suspend fun getLong(key: String, default: Long = 0L): Long
suspend fun putFloat(key: String, value: Float)
suspend fun getFloat(key: String, default: Float = 0f): Float
suspend fun putBoolean(key: String, value: Boolean)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean
suspend fun putStringSet(key: String, value: Set<String>)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String>
suspend fun putStringList(key: String, value: List<String>)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
suspend fun clear()
}
/**
* Global async settings instance
*/
expect val asyncSettings: AsyncSettings
@@ -30,4 +30,6 @@ interface Settings {
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
}
@@ -1,3 +1,4 @@
package com.pr0gramm3r101.utils.settings
expect val settings: Settings
expect val settings: Settings
expect val secureSettings: Settings
@@ -1,29 +0,0 @@
package com.pr0gramm3r101.utils.storage
/**
* Authentication token storage
*/
object AuthStorage {
private val storage = NamespacedStorage("auth")
suspend fun getToken(): String? {
val token = storage.getString("token")
return token.ifEmpty { null }
}
suspend fun setToken(token: String?) {
if (token != null) {
storage.putString("token", token)
} else {
storage.remove("token")
}
}
suspend fun clearToken() {
storage.remove("token")
}
suspend fun putString(key: String, value: String) = storage.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = storage.getString(key, default)
suspend fun remove(key: String) = storage.remove(key)
}
@@ -1,55 +0,0 @@
package com.pr0gramm3r101.utils.storage
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
/**
* Server configuration storage
*/
object ServerConfigStorage {
private val storage = NamespacedStorage("server_config")
private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled"
suspend fun getServerUrl(): String? {
val url = storage.getString(SERVER_URL_KEY)
return url.ifEmpty { null }
}
suspend fun setServerUrl(url: String) {
storage.putString(SERVER_URL_KEY, url)
}
suspend fun getHttpsEnabled(): Boolean? {
return if (storage.contains(HTTPS_ENABLED_KEY)) {
storage.getBoolean(HTTPS_ENABLED_KEY, true)
} else {
null
}
}
suspend fun setHttpsEnabled(enabled: Boolean) {
storage.putBoolean(HTTPS_ENABLED_KEY, enabled)
}
suspend fun hasConfiguration(): Boolean {
return getServerUrl() != null
}
suspend fun getConfig(): ServerConfigData {
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage")
val https = getHttpsEnabled() ?: true
return ServerConfigData(url, https)
}
suspend fun saveConfig(config: ServerConfigData) {
setServerUrl(config.serverUrl)
setHttpsEnabled(config.httpsEnabled)
}
}
@@ -1,59 +0,0 @@
package com.pr0gramm3r101.utils.storage
import com.pr0gramm3r101.utils.settings.AsyncSettings
import com.pr0gramm3r101.utils.settings.asyncSettings
/**
* Generic key-value storage using AsyncSettings
*/
class Storage(private val settings: AsyncSettings = asyncSettings) {
suspend fun putString(key: String, value: String) = settings.putString(key, value)
suspend fun getString(key: String, default: String = ""): String = settings.getString(key, default)
suspend fun putInt(key: String, value: Int) = settings.putInt(key, value)
suspend fun getInt(key: String, default: Int = 0): Int = settings.getInt(key, default)
suspend fun putLong(key: String, value: Long) = settings.putLong(key, value)
suspend fun getLong(key: String, default: Long = 0L): Long = settings.getLong(key, default)
suspend fun putFloat(key: String, value: Float) = settings.putFloat(key, value)
suspend fun getFloat(key: String, default: Float = 0f): Float = settings.getFloat(key, default)
suspend fun putBoolean(key: String, value: Boolean) = settings.putBoolean(key, value)
suspend fun getBoolean(key: String, default: Boolean = false): Boolean = settings.getBoolean(key, default)
suspend fun putStringSet(key: String, value: Set<String>) = settings.putStringSet(key, value)
suspend fun getStringSet(key: String, default: Set<String> = emptySet()): Set<String> = settings.getStringSet(key, default)
suspend fun putStringList(key: String, value: List<String>) = settings.putStringList(key, value)
suspend fun getStringList(key: String, default: List<String> = emptyList()): List<String> = settings.getStringList(key, default)
suspend fun remove(key: String) = settings.remove(key)
suspend fun contains(key: String): Boolean = settings.contains(key)
suspend fun clear() = settings.clear()
}
/**
* Namespaced storage for specific features
*/
class NamespacedStorage(private val prefix: String, private val storage: Storage = Storage()) {
private fun key(name: String) = "$prefix:$name"
suspend fun putString(name: String, value: String) = storage.putString(key(name), value)
suspend fun getString(name: String, default: String = ""): String = storage.getString(key(name), default)
suspend fun putInt(name: String, value: Int) = storage.putInt(key(name), value)
suspend fun getInt(name: String, default: Int = 0): Int = storage.getInt(key(name), default)
suspend fun putLong(name: String, value: Long) = storage.putLong(key(name), value)
suspend fun getLong(name: String, default: Long = 0L): Long = storage.getLong(key(name), default)
suspend fun putFloat(name: String, value: Float) = storage.putFloat(key(name), value)
suspend fun getFloat(name: String, default: Float = 0f): Float = storage.getFloat(key(name), default)
suspend fun putBoolean(name: String, value: Boolean) = storage.putBoolean(key(name), value)
suspend fun getBoolean(name: String, default: Boolean = false): Boolean = storage.getBoolean(key(name), default)
suspend fun remove(name: String) = storage.remove(key(name))
suspend fun contains(name: String): Boolean = storage.contains(key(name))
}
@@ -1,99 +0,0 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSBundle
import platform.Foundation.NSUserDefaults
class IosAsyncSettings : AsyncSettings {
private val defaults: NSUserDefaults = NSUserDefaults.standardUserDefaults
override suspend fun putString(key: String, value: String): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getString(key: String, default: String): String = withContext(Dispatchers.Default) {
defaults.stringForKey(key) ?: default
}
override suspend fun putInt(key: String, value: Int): Unit = withContext(Dispatchers.Default) {
defaults.setInteger(value.toLong(), key)
defaults.synchronize()
}
override suspend fun getInt(key: String, default: Int): Int = withContext(Dispatchers.Default) {
defaults.integerForKey(key).toInt()
}
override suspend fun putLong(key: String, value: Long): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getLong(key: String, default: Long): Long = withContext(Dispatchers.Default) {
(defaults.objectForKey(key) as? platform.Foundation.NSNumber)?.longValue ?: default
}
override suspend fun putFloat(key: String, value: Float): Unit = withContext(Dispatchers.Default) {
defaults.setFloat(value, key)
defaults.synchronize()
}
override suspend fun getFloat(key: String, default: Float): Float = withContext(Dispatchers.Default) {
defaults.floatForKey(key)
}
override suspend fun putBoolean(key: String, value: Boolean): Unit = withContext(Dispatchers.Default) {
defaults.setBool(value, key)
defaults.synchronize()
}
override suspend fun getBoolean(key: String, default: Boolean): Boolean = withContext(Dispatchers.Default) {
if (defaults.objectForKey(key) != null) {
defaults.boolForKey(key)
} else {
default
}
}
override suspend fun putStringSet(key: String, value: Set<String>): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value.toList(), key)
defaults.synchronize()
}
override suspend fun getStringSet(key: String, default: Set<String>): Set<String> = withContext(Dispatchers.Default) {
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
list.filterIsInstance<String>().toSet()
}
override suspend fun putStringList(key: String, value: List<String>): Unit = withContext(Dispatchers.Default) {
defaults.setObject(value, key)
defaults.synchronize()
}
override suspend fun getStringList(key: String, default: List<String>): List<String> = withContext(Dispatchers.Default) {
val list = defaults.objectForKey(key) as? List<*> ?: return@withContext default
list.filterIsInstance<String>()
}
override suspend fun remove(key: String): Unit = withContext(Dispatchers.Default) {
defaults.removeObjectForKey(key)
defaults.synchronize()
}
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
defaults.objectForKey(key) != null
}
override suspend fun clear(): Unit = withContext(Dispatchers.Default) {
val bundleId = NSBundle.mainBundle.bundleIdentifier ?: ""
val domain = defaults.persistentDomainForName(bundleId)
domain?.keys?.forEach { key ->
defaults.removeObjectForKey(key as String)
}
defaults.synchronize()
}
}
actual val asyncSettings: AsyncSettings = IosAsyncSettings()
@@ -1,5 +1,7 @@
package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults
// TODO fix
@@ -42,4 +44,8 @@ class IosSettings : Settings {
}
override suspend fun remove(key: String) = defaults.removeObjectForKey(key)
override suspend fun contains(key: String): Boolean = withContext(Dispatchers.Default) {
defaults.objectForKey(key) != null
}
}
@@ -1,3 +1,5 @@
package com.pr0gramm3r101.utils.settings
actual val settings: Settings get() = IosSettings()
actual val settings: Settings get() = IosSettings()
actual val secureSettings: Settings
get() = TODO("Not yet implemented")