5 Commits

7 changed files with 254 additions and 307 deletions
+2 -2
View File
@@ -17,9 +17,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.2</string> <string>0.2.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>02</string> <string>022</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
@@ -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 = 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()
@@ -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
)
}
}
}
}
@@ -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
) )
} }
@@ -355,13 +354,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
@@ -29,7 +29,7 @@ class PublicChatPanel(
scope.launch { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}") Logger.d("PublicChatPanel", "Typing users updated in handler: ${users.map { it.username }}")
updateState { it.copy(typingUsers = users) } updateState { it.copy(typingUsers = users.filter { it.userId != currentUserId }) }
} }
} }
} }
@@ -58,7 +58,7 @@ class PublicChatPanel(
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
messagesLoaded = true messagesLoaded = true
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoading(false) setLoading(false)
@@ -84,7 +84,7 @@ class PublicChatPanel(
} }
} }
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
} catch (e: Exception) { } catch (_: Exception) {
// Handle error // Handle error
} finally { } finally {
setLoadingMore(false) setLoadingMore(false)
@@ -4,9 +4,12 @@ import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.keyframes import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -16,7 +19,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -63,67 +65,54 @@ private fun formatTypingText(typingUsers: List<String>): String {
@Composable @Composable
private fun TypingDots() { private fun TypingDots() {
val infiniteTransition = rememberInfiniteTransition(label = "typing_dots") val infiniteTransition = rememberInfiniteTransition(label = "typing_dots")
val duration = 1000
val dot1Alpha by infiniteTransition.animateFloat( val initialScale = 1f
initialValue = 0.3f, val targetScale = 1.3f
targetValue = 1f,
// Функция-хелпер для создания анимации с задержкой
@Composable
fun animateDotScale(delay: Int) = infiniteTransition.animateFloat(
initialValue = initialScale,
targetValue = initialScale, // Возвращаемся в начало
animationSpec = infiniteRepeatable( animationSpec = infiniteRepeatable(
animation = keyframes { animation = keyframes {
durationMillis = 1400 durationMillis = duration
0.3f at 0 initialScale at delay // Начало подъема
1f at 200 targetScale at delay + 200 // Пик
0.3f at 400 initialScale at delay + 400 // Возврат
initialScale at duration // Удержание до конца цикла
} }
), ),
label = "dot1" label = "dot_scale"
)
val dot2Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 200
1f at 400
0.3f at 600
}
),
label = "dot2"
)
val dot3Alpha by infiniteTransition.animateFloat(
initialValue = 0.3f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1400
0.3f at 400
1f at 600
0.3f at 800
}
),
label = "dot3"
) )
Row(verticalAlignment = Alignment.CenterVertically) { val dot1Scale by animateDotScale(delay = 0)
Dot(alpha = dot1Alpha) val dot2Scale by animateDotScale(delay = 200)
Spacer(modifier = Modifier.width(2.dp)) val dot3Scale by animateDotScale(delay = 400)
Dot(alpha = dot2Alpha)
Spacer(modifier = Modifier.width(2.dp)) Row(
Dot(alpha = dot3Alpha) verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp) // Вместо Spacer
) {
Dot(scale = dot1Scale, maxScale = targetScale)
Dot(scale = dot2Scale, maxScale = targetScale)
Dot(scale = dot3Scale, maxScale = targetScale)
} }
} }
@Composable @Composable
private fun Dot(alpha: Float) { @Suppress("SameParameterValue")
Surface( private fun Dot(maxScale: Float, scale: Float) {
modifier = Modifier Box(
.width(4.dp) modifier = Modifier.size(4.dp * maxScale),
.height(4.dp) contentAlignment = Alignment.Center
.alpha(alpha), ) {
shape = CircleShape, Surface(
color = MaterialTheme.colorScheme.primary modifier = Modifier
) {} .width(4.dp * scale)
} .height(4.dp * scale),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary
) {}
}
}
+51 -16
View File
@@ -52,7 +52,6 @@ step() { echo -e "\n${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; }
substep() { echo -e " ${GREEN}${NC} $1"; } substep() { echo -e " ${GREEN}${NC} $1"; }
echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}" echo -e "${MAGENTA}${BOLD}🚀 FromChat KMP Release Pipeline${NC}"
[[ "$IS_PRERELEASE" == true ]] && info "Prerelease mode enabled via --pre flag"
# --- Настройка памяти для предотвращения OutOfMemory --- # --- Настройка памяти для предотвращения OutOfMemory ---
export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g" export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx8g -Dkotlin.daemon.jvm.options=-Xmx8g"
@@ -67,13 +66,14 @@ PREVTAG=${TAGS[1]:-$TAG}
substep "Current tag: ${YELLOW}$TAG${NC}" substep "Current tag: ${YELLOW}$TAG${NC}"
substep "Previous tag: ${YELLOW}$PREVTAG${NC}" substep "Previous tag: ${YELLOW}$PREVTAG${NC}"
# shellcheck disable=SC2001
BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g') BUILD_NUMBER=$(echo "$TAG" | sed 's/[^0-9]//g')
[[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1 [[ -z "$BUILD_NUMBER" ]] && BUILD_NUMBER=1
# --- Сборка Android --- # --- Сборка Android ---
build_android() { build_android() {
step "Building Android Release" step "Building Android Release"
if ./gradlew :app:android:assembleRelease -PbuildNumber="$BUILD_NUMBER" -Dorg.gradle.jvmargs="-Xmx8g"; then if ./gradlew :app:android:assembleRelease; then
APK_SRC=$(find . -name "*release.apk" | head -n 1) APK_SRC=$(find . -name "*release.apk" | head -n 1)
if [[ -f "$APK_SRC" ]]; then if [[ -f "$APK_SRC" ]]; then
@@ -101,6 +101,7 @@ build_ios() {
fi fi
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
# shellcheck disable=SC2155
[[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p) [[ ! -d "$DEVELOPER_DIR" ]] && export DEVELOPER_DIR=$(xcode-select -p)
IOS_PROJECT_DIR="app/ios" IOS_PROJECT_DIR="app/ios"
@@ -114,31 +115,34 @@ build_ios() {
fi fi
substep "Xcode Archiving (Unsigned)..." substep "Xcode Archiving (Unsigned)..."
mkdir -p build/ios
if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \ if xcodebuild -project "$IOS_PROJECT_DIR/iosApp.xcodeproj" \
-scheme iOS \ -scheme iOS \
-configuration Release \ -configuration Release \
-sdk iphoneos \ -sdk iphoneos \
-destination 'generic/platform=iOS' \ -destination 'generic/platform=iOS' \
-derivedDataPath build-output \ -derivedDataPath build/ios \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ENTITLEMENTS="" \ CODE_SIGNING_ENTITLEMENTS="" \
clean build > ios_build.log 2>&1; then build > build/ios/build.log 2>&1; then
success "Xcode build successful." success "Xcode build successful."
else else
error "Xcode build failed. Check ios_build.log" error "Xcode build failed. Check build/ios/build.log"
exit 1 exit 1
fi fi
substep "Packaging IPA for TrollStore..." substep "Packaging IPA for TrollStore..."
mkdir -p Payload mkdir -p build/ios/Payload
APP_PATH=$(find build-output -name "*.app" -type d | head -n 1) APP_PATH=$(find build/ios -name "*.app" -type d | head -n 1)
if [[ -n "$APP_PATH" ]]; then if [[ -n "$APP_PATH" ]]; then
cp -r "$APP_PATH" Payload/ cp -r "$APP_PATH" build/ios/Payload/
IPA_NAME="FromChat-$TAG-ios-unsigned.ipa" IPA_NAME="FromChat-$TAG-ios-unsigned.ipa"
zip -r "releases/$IPA_NAME" Payload > /dev/null zip -r "releases/$IPA_NAME" build/ios/Payload > /dev/null
rm -rf Payload rm -rf build/ios/Payload
IOS_ASSET="releases/$IPA_NAME" IOS_ASSET="releases/$IPA_NAME"
success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}" success "iOS IPA ready: ${CYAN}$IPA_NAME${NC}"
else else
@@ -147,6 +151,28 @@ build_ios() {
fi fi
} }
# --- Подготовка описания ---
prepare_description() {
DESC_FILE=".release_desc.md"
echo -e "<!--\nEnter the release description. Leave empty for no description.\n-->" > "$DESC_FILE"
# Открываем nano. Пользователь должен отредактировать файл.
nano "$DESC_FILE"
# Удаляем комментарии <!-- ... --> и лишние пробелы/пустые строки
# Используем perl для многострочного удаления комментариев
CLEAN_DESC=$(perl -0777 -pe 's/<!--.*?-->//gs' "$DESC_FILE" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
rm -f "$DESC_FILE"
if [[ -n "$CLEAN_DESC" ]]; then
echo "$CLEAN_DESC" > .final_notes.md
NOTES_ARG=("-F" "./.final_notes.md")
else
NOTES_ARG=("--generate-notes")
fi
}
# --- Публикация в GitHub --- # --- Публикация в GitHub ---
publish_github() { publish_github() {
step "GitHub Deployment" step "GitHub Deployment"
@@ -158,36 +184,45 @@ publish_github() {
substep "Pushing tags..." substep "Pushing tags..."
git push --tags > /dev/null 2>&1 || true git push --tags > /dev/null 2>&1 || true
# Определяем флаг пререлиза на основе тега ИЛИ аргумента --pre
prerelease_flag="" prerelease_flag=""
if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then if [[ "$IS_PRERELEASE" == true ]] || [[ "$TAG" == v*-pre* ]]; then
prerelease_flag="--prerelease" prerelease_flag="--prerelease"
fi fi
# Собираем существующие файлы в массив
ASSETS=() ASSETS=()
[[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET") [[ -f "$ANDROID_ASSET" ]] && ASSETS+=("$ANDROID_ASSET")
[[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET") [[ -f "$IOS_ASSET" ]] && ASSETS+=("$IOS_ASSET")
if [ ${#ASSETS[@]} -eq 0 ]; then if [ ${#ASSETS[@]} -eq 0 ]; then
error "No assets found to upload. Check build logs." error "No assets found to upload."
return return
fi fi
# Проверяем наличие релиза и создаем/обновляем # Подготавливаем описание перед созданием
prepare_description
if gh release view "$TAG" >/dev/null 2>&1; then if gh release view "$TAG" >/dev/null 2>&1; then
substep "Updating existing release ${YELLOW}$TAG${NC}..." substep "Updating existing release ${YELLOW}$TAG${NC}..."
[[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag" [[ -n "$prerelease_flag" ]] && gh release edit "$TAG" "$prerelease_flag"
# Если есть кастомные ноты, обновляем их
if [[ -f .final_notes.md ]]; then
gh release edit "$TAG" -F ./.final_notes.md
fi
gh release upload "$TAG" "${ASSETS[@]}" --clobber gh release upload "$TAG" "${ASSETS[@]}" --clobber
else else
substep "Creating new release ${YELLOW}$TAG${NC}..." substep "Creating new release ${YELLOW}$TAG${NC}..."
# gh release create принимает либо --generate-notes, либо --notes-file
gh release create "$TAG" \ gh release create "$TAG" \
--generate-notes \ "${NOTES_ARG[@]}" \
--notes-start-tag "${PREVTAG:-$TAG}" \ --notes-start-tag "${PREVTAG:-$TAG}" \
$prerelease_flag \ $prerelease_flag \
"${ASSETS[@]}" "${ASSETS[@]}"
fi fi
success "Assets uploaded to GitHub Release"
rm -f .final_notes.md
success "Assets and notes uploaded to GitHub Release"
} }
# --- Основной процесс --- # --- Основной процесс ---