mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 03:25:06 +03:00
Compare commits
11 Commits
@@ -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"
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -36,8 +36,3 @@ 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(
|
return Brush.linearGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color(
|
||||||
red = (r + 100).coerceIn(0, 255) / 255f,
|
red = (r + 100).coerceIn(0, 255) / 255f,
|
||||||
green = (g + 100).coerceIn(0, 255) / 255f,
|
green = (g + 100).coerceIn(0, 255) / 255f,
|
||||||
blue = (b + 100).coerceIn(0, 255) / 255f
|
blue = (b + 100).coerceIn(0, 255) / 255f
|
||||||
)
|
),
|
||||||
val color2 = Color(
|
Color(
|
||||||
red = (r + 50).coerceIn(0, 255) / 255f,
|
red = (r + 50).coerceIn(0, 255) / 255f,
|
||||||
green = (g + 50).coerceIn(0, 255) / 255f,
|
green = (g + 50).coerceIn(0, 255) / 255f,
|
||||||
blue = (b + 50).coerceIn(0, 255) / 255f
|
blue = (b + 50).coerceIn(0, 255) / 255f
|
||||||
)
|
)
|
||||||
|
),
|
||||||
return Brush.linearGradient(
|
start = Offset(0f, 0f),
|
||||||
colors = listOf(color1, color2),
|
end = Offset(100f, 100f)
|
||||||
start = androidx.compose.ui.geometry.Offset(0f, 0f),
|
|
||||||
end = androidx.compose.ui.geometry.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,52 +167,18 @@ fun ChatInput(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
|
||||||
Modifier.windowInsetsPadding(WindowInsets.navigationBars)
|
|
||||||
) {
|
|
||||||
// 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())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(Color.Transparent)
|
.background(Color.Transparent)
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp)
|
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||||
) {
|
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp)
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
) {
|
||||||
val shape = RoundedCornerShape(24.dp)
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
|
||||||
OutlinedTextField(
|
Column(
|
||||||
value = text,
|
|
||||||
onValueChange = onTextChange,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.fillMaxWidth()
|
||||||
.border(
|
.border(
|
||||||
Dp.Hairline,
|
Dp.Hairline,
|
||||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||||
@@ -142,7 +188,32 @@ fun ChatInput(
|
|||||||
.hazeEffect(
|
.hazeEffect(
|
||||||
state = hazeState,
|
state = hazeState,
|
||||||
style = HazeMaterials.thin()
|
style = HazeMaterials.thin()
|
||||||
),
|
)
|
||||||
|
) {
|
||||||
|
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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.message_placeholder),
|
text = stringResource(Res.string.message_placeholder),
|
||||||
@@ -198,93 +269,4 @@ fun ChatInput(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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
|
||||||
|
if (editingMessage != null) {
|
||||||
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,9 +108,11 @@ 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` = {
|
||||||
|
it
|
||||||
|
.shadow(
|
||||||
elevation = 8.dp,
|
elevation = 8.dp,
|
||||||
shape = RoundedCornerShape(
|
shape = RoundedCornerShape(
|
||||||
topStart = 20.dp,
|
topStart = 20.dp,
|
||||||
@@ -124,23 +122,13 @@ fun MessageItem(
|
|||||||
),
|
),
|
||||||
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
spotColor = if (isDark) Color(0x66000000) else Color(0x33000000)
|
||||||
)
|
)
|
||||||
} else {
|
.background(getMessageGradient(isDark))
|
||||||
Modifier
|
},
|
||||||
|
`else` = {
|
||||||
|
background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.background(
|
.padding(top = 6.dp)
|
||||||
brush = if (isAuthor) {
|
|
||||||
getMessageGradient(isDark)
|
|
||||||
} else {
|
|
||||||
Brush.linearGradient(
|
|
||||||
listOf(
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.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 initialScale = 1f
|
||||||
|
val targetScale = 1.3f
|
||||||
|
|
||||||
val dot1Alpha by infiniteTransition.animateFloat(
|
// Функция-хелпер для создания анимации с задержкой
|
||||||
initialValue = 0.3f,
|
@Composable
|
||||||
targetValue = 1f,
|
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(
|
val dot1Scale by animateDotScale(delay = 0)
|
||||||
initialValue = 0.3f,
|
val dot2Scale by animateDotScale(delay = 200)
|
||||||
targetValue = 1f,
|
val dot3Scale by animateDotScale(delay = 400)
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
animation = keyframes {
|
|
||||||
durationMillis = 1400
|
|
||||||
0.3f at 200
|
|
||||||
1f at 400
|
|
||||||
0.3f at 600
|
|
||||||
}
|
|
||||||
),
|
|
||||||
label = "dot2"
|
|
||||||
)
|
|
||||||
|
|
||||||
val dot3Alpha by infiniteTransition.animateFloat(
|
Row(
|
||||||
initialValue = 0.3f,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
targetValue = 1f,
|
horizontalArrangement = Arrangement.spacedBy(2.dp) // Вместо Spacer
|
||||||
animationSpec = infiniteRepeatable(
|
) {
|
||||||
animation = keyframes {
|
Dot(scale = dot1Scale, maxScale = targetScale)
|
||||||
durationMillis = 1400
|
Dot(scale = dot2Scale, maxScale = targetScale)
|
||||||
0.3f at 400
|
Dot(scale = dot3Scale, maxScale = targetScale)
|
||||||
1f at 600
|
|
||||||
0.3f at 800
|
|
||||||
}
|
|
||||||
),
|
|
||||||
label = "dot3"
|
|
||||||
)
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Dot(alpha: Float) {
|
@Suppress("SameParameterValue")
|
||||||
|
private fun Dot(maxScale: Float, scale: Float) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(4.dp * maxScale),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(4.dp)
|
.width(4.dp * scale)
|
||||||
.height(4.dp)
|
.height(4.dp * scale),
|
||||||
.alpha(alpha),
|
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
color = MaterialTheme.colorScheme.primary
|
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() }
|
||||||
|
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -76,3 +77,4 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref
|
|||||||
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
@@ -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) {
|
||||||
|
|||||||
+11
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-128
@@ -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()
|
|
||||||
+2
@@ -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)
|
|
||||||
}
|
|
||||||
-52
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
@@ -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")
|
||||||
Reference in New Issue
Block a user