4 Commits

15 changed files with 530 additions and 442 deletions
+16 -11
View File
@@ -17,21 +17,22 @@ kotlin {
} }
} }
// 1. Описываем задачу копирования с созданием правильной структуры
val fixComposeResourcesStructure = tasks.register<Copy>("fixComposeResourcesStructure") { val fixComposeResourcesStructure = tasks.register<Copy>("fixComposeResourcesStructure") {
val sharedProject = rootProject.project(":app:shared") val sharedProject = rootProject.project(":app:shared")
// Откуда берем (исходные сгенерированные файлы) from(
val sourceDir = sharedProject.layout.buildDirectory sharedProject
.dir("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources") .layout
.buildDirectory
.dir("generated/compose/resourceGenerator/preparedResources/commonMain/composeResources")
)
// Куда кладем (создаем промежуточную папку с ПРАВИЛЬНЫМ путем) into(
val outputDir = layout.buildDirectory.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat") layout
.buildDirectory
.dir("intermediates/fixed_compose_res/composeResources/ru.fromchat")
)
from(sourceDir)
into(outputDir)
// Убеждаемся, что генерация в shared уже прошла
dependsOn(sharedProject.tasks.matching { it.name.contains("prepareComposeResources", ignoreCase = true) }) dependsOn(sharedProject.tasks.matching { it.name.contains("prepareComposeResources", ignoreCase = true) })
dependsOn(sharedProject.tasks.matching { it.name.contains("copyNonXmlValueResources", ignoreCase = true) }) dependsOn(sharedProject.tasks.matching { it.name.contains("copyNonXmlValueResources", ignoreCase = true) })
} }
@@ -55,7 +56,7 @@ android {
} }
storeFile = file("keys/release.jks") storeFile = file("keys/release.jks")
keyAlias = "release" keyAlias = "key0"
storePassword = keystoreProperties["storePassword"].toString() storePassword = keystoreProperties["storePassword"].toString()
keyPassword = keystoreProperties["keyPassword"].toString() keyPassword = keystoreProperties["keyPassword"].toString()
enableV3Signing = true enableV3Signing = true
@@ -97,6 +98,10 @@ tasks.withType<MergeSourceSetFolders>().configureEach {
dependsOn(fixComposeResourcesStructure) dependsOn(fixComposeResourcesStructure)
} }
tasks.matching { it.name.contains("lintVital", ignoreCase = true) }.configureEach {
dependsOn(fixComposeResourcesStructure)
}
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict />
</plist>
+7 -7
View File
@@ -17,11 +17,16 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0</string> <string>0.2.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <string>021</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>
@@ -46,10 +51,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict> </dict>
</plist> </plist>
@@ -67,4 +67,6 @@
<string name="typing_single">%1$s is typing…</string> <string name="typing_single">%1$s is typing…</string>
<string name="typing_two">%1$s and %2$s are typing…</string> <string name="typing_two">%1$s and %2$s are typing…</string>
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string> <string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
<string name="more">More</string>
</resources> </resources>
@@ -21,6 +21,9 @@ object Config {
*/ */
suspend fun initialize() { suspend fun initialize() {
_serverConfig.value = ServerConfigStorage.getConfig() _serverConfig.value = ServerConfigStorage.getConfig()
// Set default values and prevent corruption
ServerConfigStorage.getConfig()
} }
/** /**
@@ -3,19 +3,11 @@ package ru.fromchat.ui
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.End
import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start import androidx.compose.animation.AnimatedContentTransitionScope.SlideDirection.Companion.Start
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
@@ -25,8 +17,6 @@ import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import ru.fromchat.api.WebSocketManager import ru.fromchat.api.WebSocketManager
import ru.fromchat.core.config.Config import ru.fromchat.core.config.Config
import ru.fromchat.ui.auth.LoginScreen import ru.fromchat.ui.auth.LoginScreen
@@ -39,30 +29,9 @@ val LocalNavController = compositionLocalOf<NavController> { error("") }
@Composable @Composable
fun App() { fun App() {
var startDestination by remember { mutableStateOf<String?>(null) }
// Initialize config and check server configuration on startup
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
coroutineScope { runCatching {
launch { Config.initialize()
try {
// Initialize config
Config.initialize()
// Check if server is configured
val serverConfigured = Config.hasServerConfig()
// Determine which screen to show
startDestination = if (!serverConfigured) {
"serverConfig"
} else {
"login"
}
} catch (e: Exception) {
// On error, start with server config
startDestination = "serverConfig"
}
}
} }
} }
@@ -96,82 +65,73 @@ fun App() {
CompositionLocalProvider( CompositionLocalProvider(
LocalNavController provides navController LocalNavController provides navController
) { ) {
if (startDestination == null) { NavHost(
Box( navController = navController,
modifier = androidx.compose.ui.Modifier.fillMaxSize(), startDestination = "login",
contentAlignment = Alignment.Center enterTransition = {
) { slideIntoContainer(
CircularProgressIndicator() Start,
} animationSpec = animationSpec
} else { )
NavHost( },
navController = navController, exitTransition = {
startDestination = startDestination!!, slideOutOfContainer(
enterTransition = { Start,
slideIntoContainer( animationSpec = animationSpec
Start, )
animationSpec = animationSpec },
) popEnterTransition = {
}, slideIntoContainer(
exitTransition = { End,
slideOutOfContainer( animationSpec = animationSpec
Start, )
animationSpec = animationSpec },
) popExitTransition = {
}, slideOutOfContainer(
popEnterTransition = { End,
slideIntoContainer( animationSpec = animationSpec
End, )
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
} }
} ) {
composable("serverConfig") {
ServerConfigScreen()
}
composable("login") {
LoginScreen(
onLoginSuccess = {
navController.navigate("chat") {
popUpTo("login") { inclusive = true }
}
},
onNavigateToRegister = { navController.navigate("register") }
)
}
composable("register") {
RegisterScreen(
onRegistered = { navController.navigate("login") }
)
}
composable("chat") {
MainScreen(
onLogout = {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
)
}
composable("chats/publicChat") {
PublicChatScreen()
}
composable("about") {
AboutScreen()
}
}
} }
} }
} }
@@ -1,21 +1,31 @@
package ru.fromchat.ui.auth package ru.fromchat.ui.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -33,24 +43,73 @@ import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.LoginRequest import ru.fromchat.api.LoginRequest
import ru.fromchat.api.apiRequest import ru.fromchat.api.apiRequest
import ru.fromchat.change_server
import ru.fromchat.error_unexpected import ru.fromchat.error_unexpected
import ru.fromchat.fill_all_fields import ru.fromchat.fill_all_fields
import ru.fromchat.login import ru.fromchat.login
import ru.fromchat.login_d import ru.fromchat.login_d
import ru.fromchat.more
import ru.fromchat.password import ru.fromchat.password
import ru.fromchat.register_button import ru.fromchat.register_button
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.RowHeader import ru.fromchat.ui.RowHeader
import ru.fromchat.username import ru.fromchat.username
import ru.fromchat.welcome import ru.fromchat.welcome
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen( fun LoginScreen(
onLoginSuccess: () -> Unit, onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit onNavigateToRegister: () -> Unit
) { ) {
val errorUnexpected = stringResource(Res.string.error_unexpected) val errorUnexpected = stringResource(Res.string.error_unexpected)
val navController = LocalNavController.current
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding -> Scaffold(
contentWindowInsets = WindowInsets.safeDrawing,
topBar = {
TopAppBar(
actions = {
var expanded by remember { mutableStateOf(false) }
Box(Modifier.wrapContentSize(Alignment.TopEnd)) {
IconButton(
onClick = {
expanded = true
}
) {
Icon(
imageVector = Icons.Filled.MoreVert,
contentDescription = stringResource(Res.string.more)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false } // Закрыть при нажатии вне меню
) {
DropdownMenuItem(
text = {
Text(stringResource(Res.string.change_server))
},
onClick = {
expanded = false
navController.navigate("serverConfig")
},
leadingIcon = {
Icon(
Icons.Filled.Storage,
contentDescription = null
)
}
)
}
}
},
title = {}
)
}
) { innerPadding ->
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var alert by remember { mutableStateOf<String?>(null) } var alert by remember { mutableStateOf<String?>(null) }
@@ -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,23 @@ 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(
colors = listOf(
Color(0xFF9333EA),
Color(0xFF6366F1),
Color(0xFF2F68C5)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
)
} else {
Brush.linearGradient(
colors = listOf(
Color(0xFFB794F6),
Color(0xFF818CF8),
Color(0xFF60A5FA)
),
start = androidx.compose.ui.geometry.Offset(0f, 0f),
end = androidx.compose.ui.geometry.Offset(1000f, 1000f)
)
}
}
/**
* Get background gradient brushes for chat background
*/
fun getBackgroundGradients(isDark: Boolean): List<Brush> {
return if (isDark) {
listOf( listOf(
Brush.radialGradient( Color(0xFF9333EA),
colors = listOf( Color(0xFF6366F1),
Color(0x26DBA1F9), Color(0xFF2F68C5)
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(0xFFB794F6),
colors = listOf( Color(0xFF818CF8),
Color(0x15B794F6), Color(0xFF60A5FA)
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 +36,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 = 12.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()
@@ -90,30 +170,6 @@ fun ChatInput(
Column( Column(
Modifier.windowInsetsPadding(WindowInsets.navigationBars) 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 // Input field with blur
Box( Box(
modifier = Modifier modifier = Modifier
@@ -121,28 +177,46 @@ fun ChatInput(
.background(Color.Transparent) .background(Color.Transparent)
.padding(horizontal = 8.dp, vertical = 8.dp) .padding(horizontal = 8.dp, vertical = 8.dp)
) { ) {
Row( val shape = RoundedCornerShape(24.dp)
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), Column(
verticalAlignment = Alignment.CenterVertically modifier = Modifier
.fillMaxWidth()
.border(
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
)
) { ) {
val shape = RoundedCornerShape(24.dp) AnimatedPreviewBar(replyTo) { replyTo ->
PreviewBar(
icon = Icons.AutoMirrored.Filled.Reply,
title = "Replying to ${replyTo.username}",
subtitle = replyTo.content.take(50) + if (replyTo.content.length > 50) "..." else "",
onClose = { onClearReply() }
)
}
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( OutlinedTextField(
value = text, value = text,
onValueChange = onTextChange, onValueChange = onTextChange,
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.border( .animateContentSize(),
Dp.Hairline,
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
shape
)
.clip(shape)
.hazeEffect(
state = hazeState,
style = HazeMaterials.thin()
),
placeholder = { placeholder = {
Text( Text(
text = stringResource(Res.string.message_placeholder), text = stringResource(Res.string.message_placeholder),
@@ -199,92 +273,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
)
}
}
}
}
@@ -279,7 +279,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
) )
} }
@@ -355,13 +358,17 @@ fun ChatScreen(
} }
// 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
@@ -188,20 +188,6 @@ fun SettingsTab(
onClick = { onClick = {
// Logout and navigate to server config when server changes // Logout and navigate to server config when server changes
scope.launch { scope.launch {
// Logout from current server
try {
ApiClient.token?.let { token ->
ApiClient.logout(token)
}
} catch (e: Exception) {
// Ignore logout errors (server might be unreachable)
}
// Clear API client state
ApiClient.token = null
ApiClient.user = null
WebSocketManager.shutdown()
navController.navigate("serverConfig") navController.navigate("serverConfig")
} }
}, },
@@ -52,7 +52,6 @@ import ru.fromchat.https_enabled
import ru.fromchat.save_continue import ru.fromchat.save_continue
import ru.fromchat.server_config_subtitle import ru.fromchat.server_config_subtitle
import ru.fromchat.server_config_title import ru.fromchat.server_config_title
import ru.fromchat.server_url_hint
import ru.fromchat.server_url_label import ru.fromchat.server_url_label
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
@@ -63,16 +62,16 @@ fun ServerConfigScreen() {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
// Load existing config if available // Load existing config if available
var serverUrl by remember { mutableStateOf("") } var serverUrl by remember { mutableStateOf("fromchat.ru") }
var httpsEnabled by remember { mutableStateOf(false) } var httpsEnabled by remember { mutableStateOf(true) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val config = Config.serverConfig.value Config.serverConfig.value?.let {
if (config != null) { serverUrl = it.serverUrl
serverUrl = config.serverUrl httpsEnabled = it.httpsEnabled
httpsEnabled = config.httpsEnabled
} }
} }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -82,11 +81,13 @@ fun ServerConfigScreen() {
TopAppBar( TopAppBar(
title = {}, title = {},
navigationIcon = { navigationIcon = {
IconButton(onClick = navController::navigateUp) { if (navController.currentBackStackEntry != null) {
Icon( IconButton(onClick = navController::navigateUp) {
imageVector = Icons.AutoMirrored.Filled.ArrowBack, Icon(
contentDescription = stringResource(Res.string.back) imageVector = Icons.AutoMirrored.Filled.ArrowBack,
) contentDescription = stringResource(Res.string.back)
)
}
} }
}, },
scrollBehavior = scrollBehavior scrollBehavior = scrollBehavior
@@ -122,7 +123,7 @@ fun ServerConfigScreen() {
value = serverUrl, value = serverUrl,
onValueChange = { serverUrl = it }, onValueChange = { serverUrl = it },
label = { Text(stringResource(Res.string.server_url_label)) }, label = { Text(stringResource(Res.string.server_url_label)) },
placeholder = { Text(stringResource(Res.string.server_url_hint)) }, placeholder = { Text("fromchat.ru") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true
) )
@@ -147,16 +148,13 @@ fun ServerConfigScreen() {
onClick = { onClick = {
isLoading = true isLoading = true
scope.launch { scope.launch {
val config = ServerConfigData(serverUrl, httpsEnabled) Config.updateServerConfig(ServerConfigData(serverUrl, httpsEnabled))
Config.updateServerConfig(config)
// Ensure we're logged out when server config changes // Ensure we're logged out when server config changes
try { runCatching {
ApiClient.token?.let { token -> ApiClient.token?.let { token ->
ApiClient.logout(token) ApiClient.logout(token)
} }
} catch (e: Exception) {
// Ignore logout errors
} }
// Clear API client state // Clear API client state
+191 -35
View File
@@ -1,46 +1,202 @@
#!/bin/bash #!/bin/bash
# 1. Setup # --- Настройка и окружение ---
set -e set -e
cd "$(dirname "$0")" cd "$(dirname "$0")"
# 2. Variables # --- Обработка аргументов ---
TAG=$(git describe --tags --abbrev=0) IS_PRERELEASE=false
show_help() {
echo "Usage: $0 [arguments]"
echo ""
echo "Arguments:"
echo " --pre Enable pre-release"
echo " --help Show this message"
}
for arg in "$@"; do
case $arg in
--pre)
IS_PRERELEASE=true
shift
;;
--help)
show_help
exit 0
;;
*)
echo -e "\033[0;31mError: Unknown argument '$arg'\033[0m"
show_help
exit 1
;;
esac
done
# --- Конфигурация цветов и оформления ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
MAGENTA='\033[0;35m'
NC='\033[0m' # No Color
BOLD='\033[1m'
# --- Функции логирования ---
info() { echo -e "${BLUE}${NC} $1"; }
success() { echo -e "${GREEN}${NC} $1"; }
warning() { echo -e "${YELLOW}${NC} $1"; }
error() { echo -e "${RED}${NC} $1"; }
step() { echo -e "\n${CYAN}${BOLD}${NC} ${BOLD}$1${NC}"; }
substep() { echo -e " ${GREEN}${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
[[ "$IS_PRERELEASE" == true ]] && info "Prerelease mode enabled via --pre flag"
# --- Настройка памяти для предотвращения OutOfMemory ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
# --- Переменные Git / Версии ---
step "Metadata collection"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.1")
# shellcheck disable=SC2207 # shellcheck disable=SC2207
TAGS=($(git --no-pager tag --sort -v:refname | xargs)) TAGS=($(git --no-pager tag --sort -v:refname | xargs))
PREVTAG=${TAGS[1]} PREVTAG=${TAGS[1]:-$TAG}
ASSET_PATH=./app/build/outputs/apk/release/app-release.apk
DISPLAY_NAME=FromChat.$TAG.apk
# 3. Build release variant substep "Current tag: ${YELLOW}$TAG${NC}"
./gradlew assembleRelease substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
# 4. Move locally BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android ---
build_android() {
step "Building Android Release"
if ./gradlew :app:android:assembleRelease -PbuildNumber="$BUILD_NUMBER" -Dorg.gradle.jvmargs="-Xmx8g"; then
APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then
mkdir -p releases
DISPLAY_NAME="FromChat-$TAG-android.apk"
cp "$APK_SRC" "releases/$DISPLAY_NAME"
ANDROID_ASSET="releases/$DISPLAY_NAME"
success "Android APK ready: ${CYAN}$DISPLAY_NAME${NC}"
else
error "Android APK not found after build"
exit 1
fi
else
error "Android build failed"
exit 1
fi
}
# --- Сборка iOS ---
build_ios() {
step "Building iOS Release"
if [[ "$OSTYPE" != "darwin"* ]]; then
warning "iOS build requires macOS. Skipping."
return
fi
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
IOS_PROJECT_DIR="app/ios"
[[ ! -d "$IOS_PROJECT_DIR" ]] && IOS_PROJECT_DIR="iosApp"
substep "Setting iOS version to $BUILD_NUMBER..."
PLIST_PATH=$(find "$IOS_PROJECT_DIR" -name "Info.plist" | head -n 1)
if [[ -f "$PLIST_PATH" ]]; then
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" || true
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${TAG#v}" "$PLIST_PATH" || true
fi
substep "Xcode Archiving (Unsigned)..."
mkdir -p build/ios
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-derivedDataPath build/ios \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \
build > build/ios/build.log 2>&1; then
success "Xcode build successful."
else
error "Xcode build failed. Check build/ios/build.log"
exit 1
fi
substep "Packaging IPA for TrollStore..."
mkdir -p build/ios/Payload
APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then
cp -r "$APP_PATH" build/ios/Payload/
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null
rm -rf build/ios/Payload
IOS_ASSET="releases/$IPA_NAME"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else
error "Could not find .app bundle"
exit 1
fi
}
# --- Публикация в GitHub ---
publish_github() {
step "GitHub Deployment"
if ! command -v gh &> /dev/null; then
warning "GitHub CLI (gh) not found. Skipping upload."
return
fi
substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true
# Определяем флаг пререлиза на основе тега ИЛИ аргумента --pre
prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease"
fi
# Собираем существующие файлы в массив
ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
if [ ${#ASSETS[@]} -eq 0 ]; then
error "No assets found to upload. Check build logs."
return
fi
# Проверяем наличие релиза и создаем/обновляем
if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag"
gh release upload "$TAG" "${ASSETS[@]}" --clobber
else
substep "Creating new release ${YELLOW}$TAG${NC}..."
gh release create "$TAG" \
--generate-notes \
--notes-start-tag "${PREVTAG:-$TAG}" \
$prerelease_flag \
"${ASSETS[@]}"
fi
success "Assets uploaded to GitHub Release"
}
# --- Основной процесс ---
mkdir -p releases mkdir -p releases
mv "$ASSET_PATH" "releases/$DISPLAY_NAME" build_android
ASSET_PATH=./releases/$DISPLAY_NAME build_ios
publish_github
# 6. Release to GitHub echo -e "\n${GREEN}${BOLD}✨ Release $TAG completed successfully!${NC}"
prerelease_opt=$([[ "$TAG" != v*-pre* ]] || echo "--prerelease")
new_release() {
gh release create \
"$TAG" \
--generate-notes \
--notes-start-tag "$PREVTAG" \
"$prerelease_opt" \
"$ASSET_PATH"
}
edit_existing() {
gh release edit \
"$TAG" \
"$prerelease_opt"
gh release upload \
"$TAG" \
--clobber \
"$ASSET_PATH"
}
git push --tags
new_release || edit_existing
@@ -17,34 +17,31 @@ object ServerConfigStorage {
private const val SERVER_URL_KEY = "server_url" private const val SERVER_URL_KEY = "server_url"
private const val HTTPS_ENABLED_KEY = "https_enabled" private const val HTTPS_ENABLED_KEY = "https_enabled"
suspend fun getServerUrl(): String? { suspend fun getServerUrl() = storage.getString(SERVER_URL_KEY).ifEmpty { null }
val url = storage.getString(SERVER_URL_KEY)
return url.ifEmpty { null }
}
suspend fun setServerUrl(url: String) { suspend fun setServerUrl(url: String) = storage.putString(SERVER_URL_KEY, url)
storage.putString(SERVER_URL_KEY, url)
}
suspend fun getHttpsEnabled(): Boolean? { suspend fun getHttpsEnabled() =
return if (storage.contains(HTTPS_ENABLED_KEY)) { if (storage.contains(HTTPS_ENABLED_KEY))
storage.getBoolean(HTTPS_ENABLED_KEY, true) storage.getBoolean(HTTPS_ENABLED_KEY, true)
} else { else null
null
}
}
suspend fun setHttpsEnabled(enabled: Boolean) { suspend fun setHttpsEnabled(enabled: Boolean) {
storage.putBoolean(HTTPS_ENABLED_KEY, enabled) storage.putBoolean(HTTPS_ENABLED_KEY, enabled)
} }
suspend fun hasConfiguration(): Boolean { suspend fun hasConfiguration() = getServerUrl() != null && getHttpsEnabled() != null
return getServerUrl() != null
}
suspend fun getConfig(): ServerConfigData { suspend fun getConfig(): ServerConfigData {
val url = getServerUrl() ?: throw IllegalStateException("Server URL not found in storage") var url = getServerUrl()
val https = getHttpsEnabled() ?: true var https = getHttpsEnabled()
if (url == null || https == null) {
url = "fromchat.ru"
https = true
saveConfig(ServerConfigData(url, https))
}
return ServerConfigData(url, https) return ServerConfigData(url, https)
} }