11 Commits

32 changed files with 544 additions and 906 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"
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.2</string> <string>0.2.4</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>02</string> <string>024</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
alias(libs.plugins.compose.multiplatform) alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.android.kotlin.multiplatform.library) alias(libs.plugins.android.kotlin.multiplatform.library)
kotlin("plugin.serialization") version "1.9.22" alias(libs.plugins.kotlin.serialization)
} }
kotlin { kotlin {
@@ -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 reply_to: Message? = null,
val client_message_id: String? = null, val client_message_id: String? = null,
val reactions: List<ReactionData>? = null val reactions: List<ReactionData>? = null
) { )
val utcTimestamp = "${timestamp}Z"
}
@Serializable @Serializable
data class SendMessageRequest( data class SendMessageRequest(
@@ -35,9 +35,4 @@ data class ReactionUser(
data class TypingUpdateData( data class TypingUpdateData(
val userId: Int, val userId: Int,
val username: String val username: String
)
@Serializable
data class WebSocketAuthMessage(
val token: String
) )
@@ -8,19 +8,75 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import ru.fromchat.ui.Theme import ru.fromchat.ui.Theme
/**
* Server configuration data
*/
data class ServerConfigData(
val serverUrl: String,
val httpsEnabled: Boolean
)
object Settings { 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 val settings = Settings()
private fun runIO(block: suspend CoroutineScope.() -> Unit) { private fun runIO(block: suspend CoroutineScope.() -> Unit) {
CoroutineScope(Dispatchers.IO).launch(block = block) CoroutineScope(Dispatchers.IO).launch(block = block)
} }
private fun serverConfigNotInitialized(): Nothing
= throw IllegalStateException("Server config not initialized")
var materialYou: Boolean var materialYou: Boolean
get() = runBlocking { settings.getBoolean("materialYou", true) } get() = runBlocking { settings.getBoolean(MATERIAL_YOU_KEY, true) }
set(value) = runIO { settings.putBoolean("materialYou", value) } set(value) = runIO { settings.putBoolean(MATERIAL_YOU_KEY, value) }
var theme: Theme var theme: Theme
get() = runBlocking { Theme.entries[settings.getInt("theme", Theme.AsSystem.ordinal)] } get() = runBlocking { Theme.entries[settings.getInt(THEME_KEY, Theme.AsSystem.ordinal)] }
set(value) = runIO { settings.putInt("theme", value.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 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.Settings
/** /**
* Application configuration * Application configuration
@@ -19,18 +19,15 @@ object Config {
/** /**
* Initialize configuration by loading from storage * Initialize configuration by loading from storage
*/ */
suspend fun initialize() { fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig() _serverConfig.value = Settings.serverConfig
// Set default values and prevent corruption
ServerConfigStorage.getConfig()
} }
/** /**
* Update server configuration * Update server configuration
*/ */
suspend fun updateServerConfig(config: ServerConfigData) { fun updateServerConfig(config: ServerConfigData) {
ServerConfigStorage.saveConfig(config) Settings.serverConfig = config
_serverConfig.value = config _serverConfig.value = config
} }
@@ -45,9 +42,4 @@ object Config {
*/ */
val webSocketUrl val webSocketUrl
get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws" get() = "${if (config.httpsEnabled) "wss" else "ws"}://${config.serverUrl}/api/chat/ws"
/**
* Checks if server configuration exists
*/
suspend fun hasServerConfig() = ServerConfigStorage.hasConfiguration()
} }
@@ -1,5 +1,6 @@
package ru.fromchat.ui.chat package ru.fromchat.ui.chat
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import kotlin.math.abs import kotlin.math.abs
@@ -7,90 +8,44 @@ import kotlin.math.abs
/** /**
* Get gradient brush for own messages * Get gradient brush for own messages
*/ */
fun getMessageGradient(isDark: Boolean): Brush { fun getMessageGradient(isDark: Boolean) = Brush.linearGradient(
return if (isDark) { colors = if (isDark) {
Brush.linearGradient( listOf(
colors = listOf( Color(0xFF9333EA),
Color(0xFF9333EA), Color(0xFF6366F1),
Color(0xFF6366F1), Color(0xFF2F68C5)
Color(0xFF2F68C5)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
) )
} else { } else {
Brush.linearGradient( listOf(
colors = listOf( Color(0xFFB794F6),
Color(0xFFB794F6), Color(0xFF818CF8),
Color(0xFF818CF8), Color(0xFF60A5FA)
Color(0xFF60A5FA)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
) )
} },
} 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> { fun getReplyMessageGradient(isDark: Boolean) = Brush.linearGradient(
return if (isDark) { colors = if (isDark) {
listOf( listOf(
Brush.radialGradient( Color(0xFF5f11a6),
colors = listOf( Color(0xFF1418da),
Color(0x26DBA1F9), Color(0xFF1b3d73)
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
)
) )
} else { } else {
listOf( listOf(
Brush.radialGradient( Color(0xFF7836ee),
colors = listOf( Color(0xFF2034f3),
Color(0x15B794F6), Color(0xFF076eed)
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
)
) )
} },
} start = Offset(0f, 0f),
end = Offset(1000f, 1000f)
)
/** /**
* Generate a consistent gradient from a name for avatar fallback * 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) val b = abs((hash / 65536) % 256)
// Create two colors based on hash for gradient // 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( return Brush.linearGradient(
colors = listOf(color1, color2), colors = listOf(
start = androidx.compose.ui.geometry.Offset(0f, 0f), Color(
end = androidx.compose.ui.geometry.Offset(100f, 100f) 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 package ru.fromchat.ui.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.tween 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.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background 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.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -41,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@@ -56,6 +63,78 @@ import ru.fromchat.Res
import ru.fromchat.api.Message import ru.fromchat.api.Message
import ru.fromchat.message_placeholder 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) @OptIn(ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun ChatInput( fun ChatInput(
@@ -77,6 +156,7 @@ fun ChatInput(
if (text.isNotBlank()) { if (text.isNotBlank()) {
typingJob?.cancel() typingJob?.cancel()
typingHandler.sendTyping() typingHandler.sendTyping()
@Suppress("AssignedValueIsNeverRead")
typingJob = scope.launch { typingJob = scope.launch {
delay(3000) // 3 seconds delay(3000) // 3 seconds
typingHandler.stopTyping() typingHandler.stopTyping()
@@ -87,204 +167,106 @@ fun ChatInput(
} }
} }
Column( Box(
Modifier.windowInsetsPadding(WindowInsets.navigationBars) modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
) { ) {
// Reply preview val shape = RoundedCornerShape(24.dp)
replyTo?.let { reply ->
ReplyPreviewBar(
replyTo = reply,
onClose = onClearReply,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.hazeEffect(hazeState, style = HazeMaterials.thick())
)
}
// Edit preview Column(
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(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Transparent) .border(
.padding(horizontal = 8.dp, vertical = 8.dp) Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
)
) { ) {
Row( AnimatedPreviewBar(replyTo) { replyTo ->
modifier = Modifier.fillMaxWidth(), PreviewBar(
horizontalArrangement = Arrangement.spacedBy(8.dp), icon = Icons.AutoMirrored.Filled.Reply,
verticalAlignment = Alignment.CenterVertically title = "Replying to ${replyTo.username}",
) { subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
val shape = RoundedCornerShape(24.dp) onClose = { onClearReply() }
)
}
OutlinedTextField( AnimatedPreviewBar(editingMessage) { message ->
value = text, PreviewBar(
onValueChange = onTextChange, icon = Icons.Filled.Edit,
modifier = Modifier title = "Editing message",
.weight(1f) subtitle = message.content.take(50) + if (message.content.length > 50) "..." else "",
.border( onClose = { onClearEdit() }
Dp.Hairline, )
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), }
shape
) OutlinedTextField(
.clip(shape) value = text,
.hazeEffect( onValueChange = onTextChange,
state = hazeState, modifier = Modifier
style = HazeMaterials.thin() .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 = { exit = slideOutHorizontally(
Text( targetOffsetX = { it + offset },
text = stringResource(Res.string.message_placeholder), animationSpec = tween(durationMillis = 200)
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
) )
}, ) {
shape = shape, Box(Modifier.padding(end = 5.dp)) {
maxLines = 5, FilledIconButton(
singleLine = false, onClick = {
colors = OutlinedTextFieldDefaults.colors( onSend(text.trim())
focusedContainerColor = Color.Transparent, onTextChange("")
unfocusedContainerColor = Color.Transparent, typingHandler.stopTyping()
errorContainerColor = Color.Transparent, },
disabledContainerColor = Color.Transparent, modifier = Modifier.size(36.dp)
focusedBorderColor = Color.Transparent, ) {
errorBorderColor = Color.Transparent, Icon(
disabledBorderColor = Color.Transparent, imageVector = Icons.AutoMirrored.Filled.Send,
unfocusedBorderColor = Color.Transparent contentDescription = "Send",
), tint = MaterialTheme.colorScheme.onPrimary,
trailingIcon = { modifier = Modifier.size(18.dp)
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)
)
}
} }
} }
} }
) }
} )
} }
} }
} }
@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 = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Column( Column(Modifier.fillMaxWidth()) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = panelState.title, text = panelState.title,
style = MaterialTheme.typography.titleLarge style = MaterialTheme.typography.titleLarge
) )
AnimatedContent( AnimatedContent(
targetState = currentTypingUsers.isNotEmpty(), targetState = currentTypingUsers.isNotEmpty(),
transitionSpec = { transitionSpec = {
@@ -207,9 +206,6 @@ fun ChatScreen(
typingUsers = currentTypingUsers.map { it.username }, typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp) 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, replyTo = replyTo,
editingMessage = editingMessage, editingMessage = editingMessage,
onClearReply = { replyTo = null }, onClearReply = { replyTo = null },
onClearEdit = { editingMessage = null }, onClearEdit = {
editingMessage = null
inputText = ""
},
hazeState = hazeState hazeState = hazeState
) )
} }
@@ -308,12 +307,13 @@ fun ChatScreen(
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), // Fill the entire space of the Box 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(
items = panelState.messages, items = panelState.messages.reversed(),
key = { it.id } key = { it.id }
) { message -> ) { message ->
val isAuthor = message.user_id == currentUserId 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 // Context menu
@Suppress("AssignedValueIsNeverRead")
MessageContextMenu( MessageContextMenu(
state = contextMenuState, state = contextMenuState,
isAuthor = contextMenuState.message?.user_id == currentUserId, isAuthor = contextMenuState.message?.user_id == currentUserId,
onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) }, onDismiss = { contextMenuState = contextMenuState.copy(isOpen = false) },
onReply = { message -> onReply = { message ->
replyTo = message replyTo = message
editingMessage = null if (editingMessage != null) {
editingMessage = null
inputText = ""
}
}, },
onEdit = { message -> onEdit = { message ->
editingMessage = message editingMessage = message
@@ -12,9 +12,12 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn 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.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.pr0gramm3r101.utils.conditional
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import ru.fromchat.api.Message import ru.fromchat.api.Message
@@ -92,18 +95,11 @@ fun MessageItem(
.widthIn(max = 280.dp), .widthIn(max = 280.dp),
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start 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 // Message bubble
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
Box( Box(
modifier = Modifier modifier = Modifier
.width(IntrinsicSize.Min)
.clip( .clip(
RoundedCornerShape( RoundedCornerShape(
topStart = 20.dp, topStart = 20.dp,
@@ -112,35 +108,27 @@ fun MessageItem(
bottomEnd = if (isAuthor) 8.dp else 20.dp bottomEnd = if (isAuthor) 8.dp else 20.dp
) )
) )
.then( .conditional(
if (isAuthor) { isAuthor,
Modifier.shadow( `if` = {
elevation = 8.dp, it
shape = RoundedCornerShape( .shadow(
topStart = 20.dp, elevation = 8.dp,
topEnd = 20.dp, shape = RoundedCornerShape(
bottomStart = 20.dp, topStart = 20.dp,
bottomEnd = 8.dp topEnd = 20.dp,
), bottomStart = 20.dp,
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000) bottomEnd = 8.dp
) ),
} else { spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
Modifier
}
)
.background(
brush = if (isAuthor) {
getMessageGradient(isDark)
} else {
Brush.linearGradient(
listOf(
MaterialTheme.colorScheme.surfaceContainerHighest,
MaterialTheme.colorScheme.surfaceContainerHighest
) )
) .background(getMessageGradient(isDark))
},
`else` = {
background(MaterialTheme.colorScheme.surfaceContainerHighest)
} }
) )
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(top = 6.dp)
) { ) {
Column { Column {
// Username inside bubble (for received messages) // Username inside bubble (for received messages)
@@ -150,10 +138,59 @@ fun MessageItem(
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, 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 // Message content
Text( Text(
text = message.content, text = message.content,
@@ -162,12 +199,14 @@ fun MessageItem(
Color.White Color.White
} else { } else {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} },
modifier = Modifier.padding(horizontal = 12.dp)
) )
// Timestamp and edited indicator // Timestamp and edited indicator
Row( 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, horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically 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 @ExperimentalTime
private fun formatTime(timestamp: String): String { private fun formatTime(timestamp: String): String {
return try { return try {
val instant = Instant.parse(timestamp) Instant.parse(timestamp).toLocalDateTime(TimeZone.currentSystemDefault()).let {
val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault()) "${
val hour = localDateTime.hour.toString().padStart(2, '0') it.hour.toString().padStart(2, '0')
val minute = localDateTime.minute.toString().padStart(2, '0') }:${
"$hour:$minute" it.minute.toString().padStart(2, '0')
} catch (e: Exception) { }"
}
} catch (_: Exception) {
// Fallback: try parsing without timezone if it fails // Fallback: try parsing without timezone if it fails
try { try {
val parts = timestamp.split("T") val parts = timestamp.split("T")
@@ -254,9 +265,8 @@ private fun formatTime(timestamp: String): String {
} else { } else {
"" ""
} }
} catch (e2: Exception) { } catch (_: Exception) {
"" ""
} }
} }
} }
@@ -29,7 +29,7 @@ class PublicChatPanel(
scope.launch { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") 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 setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true messagesLoaded = true
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoading(false) setLoading(false)
@@ -84,7 +84,7 @@ class PublicChatPanel(
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoadingMore(false) setLoadingMore(false)
@@ -4,9 +4,12 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -63,67 +65,54 @@ private fun formatTypingText(typingUsers: List<String>): String {
@Composable @Composable
private fun TypingDots() { private fun TypingDots() {
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots") val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
val duration = 1000
val dot1Alpha by infiniteTransition.animateFloat( val initialScale = 1f
initialValue = 0.3f, val targetScale = 1.3f
targetValue = 1f,
// Функция-хелпер для создания анимации с задержкой
@Composable
fun animateDotScale(delay: Int) = infiniteTransition.animateFloat(
initialValue = initialScale,
targetValue = initialScale, // Возвращаемся в начало
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = keyframes { animation = keyframes {
durationMillis = 1400 durationMillis = duration
0.3f at 0 initialScale at delay // Начало подъема
1f at 200 targetScale at delay + 200 // Пик
0.3f at 400 initialScale at delay + 400 // Возврат
initialScale at duration // Удержание до конца цикла
} }
), ),
label = "dot1" label = "dot_scale"
)
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"
) )
Row(verticalAlignment = Alignment.CenterVertically) { val dot1Scale by animateDotScale(delay = 0)
Dot(alpha = dot1Alpha) val dot2Scale by animateDotScale(delay = 200)
Spacer(modifier = Modifier.width(2.dp)) val dot3Scale by animateDotScale(delay = 400)
Dot(alpha = dot2Alpha)
Spacer(modifier = Modifier.width(2.dp)) Row(
Dot(alpha = dot3Alpha) 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 @Composable
private fun Dot(alpha: Float) { @Suppress("SameParameterValue")
Surface( private fun Dot(maxScale: Float, scale: Float) {
modifier = Modifier Box(
.width(4.dp) modifier = Modifier.size(4.dp * maxScale),
.height(4.dp) contentAlignment = Alignment.Center
.alpha(alpha), ) {
shape = CircleShape, Surface(
color = MaterialTheme.colorScheme.primary modifier = Modifier
) {} .width(4.dp * scale)
} .height(4.dp * scale),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {}
}
}
@@ -40,13 +40,13 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.navigateAndWipeBackStack import com.pr0gramm3r101.utils.navigateAndWipeBackStack
import com.pr0gramm3r101.utils.storage.ServerConfigData
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.back import ru.fromchat.back
import ru.fromchat.core.ServerConfigData
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.https_enabled import ru.fromchat.https_enabled
import ru.fromchat.save_continue import ru.fromchat.save_continue
@@ -1,7 +1,8 @@
package ru.fromchat package ru.fromchat
import ru.fromchat.ui.App
import androidx.compose.ui.window.ComposeUIViewController import androidx.compose.ui.window.ComposeUIViewController
import ru.fromchat.ui.App
@Suppress("unused")
fun MainViewController() = ComposeUIViewController { App() } fun MainViewController() = ComposeUIViewController { App() }
+5 -3
View File
@@ -17,7 +17,8 @@ gson = "2.13.2"
kotlinxIoBytestring = "0.8.2" kotlinxIoBytestring = "0.8.2"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.8.2" kotlinxIoCore = "0.8.2"
kotlinxSerializationJson = "1.9.0" serialization = "2.3.0"
serialization-json = "1.9.0"
material = "1.13.0" material = "1.13.0"
activityKtx = "1.12.2" activityKtx = "1.12.2"
navigationCompose = "2.9.1" 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" } biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" } gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIoCore" } 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" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" } androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigationCompose" } 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" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } 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" } 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" }
+51 -16
View File
@@ -52,7 +52,6 @@ step() { echo -e "\n${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
substep() { echo -e " ${GREEN}${NC} $1"; } substep() { echo -e " ${GREEN}${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}" echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
[[ "$IS_PRERELEASE" == true ]] && info "Prerelease mode enabled via --pre flag"
# --- Настройка памяти для предотвращения OutOfMemory --- # --- Настройка памяти для предотвращения OutOfMemory ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g" export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
@@ -67,13 +66,14 @@ PREVTAG=${TAGS[1]:-$TAG}
substep "Current tag: ${YELLOW}$TAG${NC}" substep "Current tag: ${YELLOW}$TAG${NC}"
substep "Previous tag: ${YELLOW}$PREVTAG${NC}" substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g') BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1 [[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android --- # --- Сборка Android ---
build_android() { build_android() {
step "Building Android Release" step "Building Android Release"
if ./gradlew :app:android:assembleRelease -PbuildNumber="$BUILD_NUMBER" -Dorg.gradle.jvmargs="-Xmx8g"; then if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1) APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then if [[ -f "$APK_SRC" ]]; then
@@ -101,6 +101,7 @@ build_ios() {
fi fi
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
# shellcheck disable=SC2155
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p) [[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
IOS_PROJECT_DIR="app/ios" IOS_PROJECT_DIR="app/ios"
@@ -114,31 +115,34 @@ build_ios() {
fi fi
substep "Xcode Archiving (Unsigned)..." substep "Xcode Archiving (Unsigned)..."
mkdir -p build/ios
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \ if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \ -scheme iOS \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-derivedDataPath build-output \ -derivedDataPath build/ios \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \ CODE_SIGNING_ENTITLEMENTS="" \
clean build > ios_build.log 2>&1; then build > build/ios/build.log 2>&1; then
success "Xcode build successful." success "Xcode build successful."
else else
error "Xcode build failed. Check ios_build.log" error "Xcode build failed. Check build/ios/build.log"
exit 1 exit 1
fi fi
substep "Packaging IPA for TrollStore..." substep "Packaging IPA for TrollStore..."
mkdir -p Payload mkdir -p build/ios/Payload
APP_PATH=$(find build-output -name "*.app" -type d | head -n 1) APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then if [[ -n "$APP_PATH" ]]; then
cp -r "$APP_PATH" Payload/ cp -r "$APP_PATH" build/ios/Payload/
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa" IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" Payload > /dev/null zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null
rm -rf Payload rm -rf build/ios/Payload
IOS_ASSET="releases/$IPA_NAME" IOS_ASSET="releases/$IPA_NAME"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}" success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else else
@@ -147,6 +151,28 @@ build_ios() {
fi 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 --- # --- Публикация в GitHub ---
publish_github() { publish_github() {
step "GitHub Deployment" step "GitHub Deployment"
@@ -158,36 +184,45 @@ publish_github() {
substep "Pushing tags..." substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true git push --tags > /dev/null 2>&1 || true
# Определяем флаг пререлиза на основе тега ИЛИ аргумента --pre
prerelease_flag="" prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease" prerelease_flag="--prerelease"
fi fi
# Собираем существующие файлы в массив
ASSETS=() ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET") [[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET") [[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
if [ ${#ASSETS[@]} -eq 0 ]; then if [ ${#ASSETS[@]} -eq 0 ]; then
error "No assets found to upload. Check build logs." error "No assets found to upload."
return return
fi fi
# Проверяем наличие релиза и создаем/обновляем # Подготавливаем описание перед созданием
prepare_description
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..." substep "Updating existing release ${YELLOW}$TAG${NC}..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag" [[ -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 gh release upload "$TAG" "${ASSETS[@]}" --clobber
else else
substep "Creating new release ${YELLOW}$TAG${NC}..." substep "Creating new release ${YELLOW}$TAG${NC}..."
# gh release create принимает либо --generate-notes, либо --notes-file
gh release create "$TAG" \ gh release create "$TAG" \
--generate-notes \ "${NOTES_ARG[@]}" \
--notes-start-tag "${PREVTAG:-$TAG}" \ --notes-start-tag "${PREVTAG:-$TAG}" \
$prerelease_flag \ $prerelease_flag \
"${ASSETS[@]}" "${ASSETS[@]}"
fi fi
success "Assets uploaded to GitHub Release"
rm -f .final_notes.md
success "Assets and notes uploaded to GitHub Release"
} }
# --- Основной процесс --- # --- Основной процесс ---
@@ -720,6 +720,7 @@ val ActivityInfo.launchIntent get() = Intent().apply {
flags = FLAG_ACTIVITY_NEW_TASK flags = FLAG_ACTIVITY_NEW_TASK
} }
@SuppressLint("QueryPermissionsNeeded")
@RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES) @RequiresPermission(Manifest.permission.QUERY_ALL_PACKAGES)
fun ActivityInfo.isLauncher(context: Context): Boolean { fun ActivityInfo.isLauncher(context: Context): Boolean {
with (context) { with (context) {
@@ -93,4 +93,15 @@ class AndroidSettings(): Settings {
preferences.remove(stringSetPreferencesKey(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()
}
} }
@@ -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 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 package com.pr0gramm3r101.utils
import androidx.compose.ui.Modifier
import androidx.navigation.NavController import androidx.navigation.NavController
/** /**
@@ -21,18 +22,22 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
val currentRoute = currentBackStackEntry?.destination?.route val currentRoute = currentBackStackEntry?.destination?.route
// Navigate to the new route, removing the current screen as well // 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) { popUpTo(currentRoute) {
inclusive = true 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 getStringList(key: String, default: List<String> = emptyList()): List<String>
suspend fun remove(key: String) suspend fun remove(key: String)
suspend fun contains(key: String): Boolean
} }
@@ -1,3 +1,4 @@
package com.pr0gramm3r101.utils.settings 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,52 +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() = storage.getString(SERVER_URL_KEY).ifEmpty { null }
suspend fun setServerUrl(url: String) = storage.putString(SERVER_URL_KEY, url)
suspend fun getHttpsEnabled() =
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() = getServerUrl() != null && getHttpsEnabled() != null
suspend fun getConfig(): ServerConfigData {
var url = getServerUrl()
var https = getHttpsEnabled()
if (url == null || https == null) {
url = "fromchat.ru"
https = true
saveConfig(ServerConfigData(url, https))
}
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 package com.pr0gramm3r101.utils.settings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import platform.Foundation.NSUserDefaults import platform.Foundation.NSUserDefaults
// TODO fix // TODO fix
@@ -42,4 +44,8 @@ class IosSettings : Settings {
} }
override suspend fun remove(key: String) = defaults.removeObjectForKey(key) 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 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")