feat: enhance camera handling and UI state management in chat components

This commit is contained in:
2026-03-02 14:07:42 +05:00
parent 6d379148b0
commit 8f7544c655
6 changed files with 691 additions and 148 deletions

View File

@@ -1072,6 +1072,9 @@ fun MainScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey, accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey, accountPrivateKey = accountPrivateKey,
accountName = accountName,
accountUsername = accountUsername,
avatarRepository = avatarRepository,
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } }, onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
onGroupOpened = { groupUser -> onGroupOpened = { groupUser ->
navStack = navStack =

View File

@@ -633,6 +633,14 @@ fun ChatDetailScreen(
isScreenActive = false isScreenActive = false
viewModel.setDialogActive(false) viewModel.setDialogActive(false)
} }
Lifecycle.Event.ON_STOP -> {
// Hard-stop camera/picker overlays when app goes background.
// On next app open everything must start closed/off.
showInAppCamera = false
showMediaPicker = false
pendingCameraPhotoUri = null
pendingGalleryImages = emptyList()
}
else -> {} else -> {}
} }
} }

View File

@@ -1,61 +1,175 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.net.Uri
import android.app.Activity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
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.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) private enum class GroupSetupStep {
DETAILS,
DESCRIPTION
}
@Composable @Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
fun GroupSetupScreen( fun GroupSetupScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
accountPublicKey: String, accountPublicKey: String,
accountPrivateKey: String, accountPrivateKey: String,
accountName: String,
accountUsername: String,
avatarRepository: AvatarRepository? = null,
onBack: () -> Unit, onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit onGroupOpened: (SearchUser) -> Unit
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val nameFocusRequester = remember { FocusRequester() }
var selectedTab by remember { mutableIntStateOf(0) } var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
var title by remember { mutableStateOf("") } var title by rememberSaveable { mutableStateOf("") }
var description by remember { mutableStateOf("") } var description by rememberSaveable { mutableStateOf("") }
var inviteString by remember { mutableStateOf("") } var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) } var isLoading by rememberSaveable { mutableStateOf(false) }
var errorText by remember { mutableStateOf<String?>(null) } var errorText by rememberSaveable { mutableStateOf<String?>(null) }
var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) }
var showPhotoPicker by rememberSaveable { mutableStateOf(false) }
val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
val cropError = ImageCropHelper.getCropError(result)
if (croppedUri != null) {
selectedAvatarUri = croppedUri.toString()
} else if (cropError != null) {
android.widget.Toast
.makeText(context, "Failed to crop photo", android.widget.Toast.LENGTH_SHORT)
.show()
}
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val topSurfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val sectionColor = if (isDarkTheme) Color(0xFF222224) else Color.White
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
val window = (view.context as? Activity)?.window
if (window == null) {
onDispose { }
} else {
val controller = WindowCompat.getInsetsController(window, view)
val previousColor = window.statusBarColor
val previousLightIcons = controller.isAppearanceLightStatusBars
window.statusBarColor = topSurfaceColor.toArgb()
controller.isAppearanceLightStatusBars = false
onDispose {
window.statusBarColor = previousColor
controller.isAppearanceLightStatusBars = previousLightIcons
}
}
}
val normalizedUsername = remember(accountUsername) {
accountUsername.trim().trimStart('@')
}
val selfTitle =
remember(accountName, normalizedUsername, accountPublicKey) {
accountName.trim()
.ifBlank { normalizedUsername }
.ifBlank { shortPublicKey(accountPublicKey) }
}
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you"
fun openGroup(dialogPublicKey: String, groupTitle: String) { fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened( onGroupOpened(
@@ -77,13 +191,6 @@ fun GroupSetupScreen(
description = description.trim() description = description.trim()
) )
suspend fun joinGroup() =
GroupRepository.getInstance(context).joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = inviteString.trim()
)
fun mapError(status: GroupStatus, fallback: String): String { fun mapError(status: GroupStatus, fallback: String): String {
return when (status) { return when (status) {
GroupStatus.BANNED -> "You are banned in this group" GroupStatus.BANNED -> "You are banned in this group"
@@ -92,157 +199,523 @@ fun GroupSetupScreen(
} }
} }
fun handleBack() {
if (isLoading) return
errorText = null
if (step == GroupSetupStep.DESCRIPTION) {
step = GroupSetupStep.DETAILS
} else {
onBack()
}
}
BackHandler(onBack = ::handleBack)
val canGoNext = title.trim().isNotEmpty()
val canCreate = canGoNext && !isLoading
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val navBottomPx = WindowInsets.navigationBars.getBottom(density)
val keyboardHeightPx = (imeBottomPx - navBottomPx).coerceAtLeast(0)
val fabBottomPadding =
if (keyboardHeightPx > 0) {
with(density) { keyboardHeightPx.toDp() } + 14.dp
} else {
18.dp
}
LaunchedEffect(step) {
if (step != GroupSetupStep.DETAILS) {
showEmojiKeyboard = false
}
}
Scaffold( Scaffold(
containerColor = backgroundColor,
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Groups", fontWeight = FontWeight.SemiBold) }, title = {
Text(
text = if (step == GroupSetupStep.DETAILS) "New Group" else "Group Description",
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 20.sp
)
},
navigationIcon = { navigationIcon = {
TextButton(onClick = onBack) { IconButton(onClick = ::handleBack) {
Text("Back") Icon(
painter = TelegramIcons.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
} }
} },
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = topSurfaceColor,
titleContentColor = Color.White,
navigationIconContentColor = Color.White
)
) )
},
bottomBar = {
if (step == GroupSetupStep.DETAILS && showEmojiKeyboard) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emojiCode ->
val emoji = decodeEmojiCodeToUnicode(emojiCode)
title = (title + emoji).take(80)
},
modifier = Modifier.fillMaxWidth().navigationBarsPadding()
)
}
} }
) { paddingValues -> ) { paddingValues ->
Column( Box(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.Top
) { ) {
TabRow(selectedTabIndex = selectedTab) { Column(
Tab( modifier =
selected = selectedTab == 0, Modifier
onClick = { .fillMaxSize()
selectedTab = 0 .padding(horizontal = 16.dp)
errorText = null .navigationBarsPadding()
}, ) {
text = { Text("Create") } if (step == GroupSetupStep.DETAILS) {
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
errorText = null
},
text = { Text("Join") }
)
}
Spacer(modifier = Modifier.height(16.dp))
if (selectedTab == 0) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Group title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optional)") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
minLines = 3,
maxLines = 4
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { Row(
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot create group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = title.trim().isNotEmpty() && !isLoading verticalAlignment = Alignment.CenterVertically
) { ) {
if (isLoading) { Box(
CircularProgressIndicator(strokeWidth = 2.dp) contentAlignment = Alignment.Center,
} else { modifier =
Text("Create Group") Modifier
.size(72.dp)
) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(CircleShape)
.background(sectionColor)
.clickable(enabled = !isLoading) {
showPhotoPicker = true
},
contentAlignment = Alignment.Center
) {
if (!selectedAvatarUri.isNullOrBlank()) {
AsyncImage(
model =
ImageRequest.Builder(context)
.data(Uri.parse(selectedAvatarUri))
.crossfade(true)
.build(),
contentDescription = "Group avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Camera,
contentDescription = "Set group avatar",
tint = accentColor,
modifier = Modifier.size(24.dp)
)
}
}
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = title,
onValueChange = { newValue -> title = newValue.take(80) },
singleLine = true,
textStyle = TextStyle(
color = primaryTextColor,
fontSize = 18.sp,
fontWeight = FontWeight.Medium
),
cursorBrush = SolidColor(accentColor),
enabled = !isLoading,
modifier =
Modifier
.fillMaxWidth()
.focusRequester(nameFocusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
showEmojiKeyboard = false
}
}
.padding(vertical = 2.dp),
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
if (title.isBlank()) {
Text(
text = "Group name",
color = secondaryTextColor.copy(alpha = 0.88f),
fontSize = 18.sp,
fontWeight = FontWeight.Normal
)
}
innerTextField()
}
}
)
}
IconButton(
onClick = {
if (showEmojiKeyboard) {
showEmojiKeyboard = false
nameFocusRequester.requestFocus()
keyboardController?.show()
} else {
showEmojiKeyboard = true
focusManager.clearFocus(force = true)
keyboardController?.hide()
}
},
enabled = !isLoading
) {
Icon(
painter = TelegramIcons.Smile,
contentDescription = "Emoji",
tint = secondaryTextColor,
modifier = Modifier.size(22.dp)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier =
Modifier
.fillMaxWidth()
.height(2.dp)
.background(accentColor.copy(alpha = 0.9f), RoundedCornerShape(1.dp))
)
} }
} }
} else {
OutlinedTextField( Spacer(modifier = Modifier.height(22.dp))
value = inviteString,
onValueChange = { inviteString = it }, Text(
label = { Text("Invite string") }, text = "1 member",
modifier = Modifier.fillMaxWidth(), color = accentColor,
minLines = 3, fontWeight = FontWeight.Medium,
maxLines = 6, fontSize = 15.sp,
shape = RoundedCornerShape(12.dp) modifier = Modifier.padding(bottom = 0.dp)
) )
Spacer(modifier = Modifier.height(16.dp))
Button( Spacer(modifier = Modifier.height(8.dp))
onClick = {
if (isLoading) return@Button Row(
errorText = null modifier =
isLoading = true Modifier
scope.launch { .fillMaxWidth()
val result = withContext(Dispatchers.IO) { joinGroup() } .clip(RoundedCornerShape(14.dp))
if (result.success && !result.dialogPublicKey.isNullOrBlank()) { .background(sectionColor)
openGroup(result.dialogPublicKey, result.title) .padding(horizontal = 14.dp, vertical = 12.dp),
} else { verticalAlignment = Alignment.CenterVertically
errorText =
mapError(
result.status,
result.error ?: "Cannot join group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = inviteString.trim().isNotEmpty() && !isLoading
) { ) {
if (isLoading) { AvatarImage(
CircularProgressIndicator(strokeWidth = 2.dp) publicKey = accountPublicKey,
} else { avatarRepository = avatarRepository,
Text("Join Group") size = 50.dp,
isDarkTheme = isDarkTheme,
displayName = selfTitle
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = selfTitle,
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
contentDescription = "Admin",
tint = Color(0xFFF6C445),
modifier = Modifier.size(14.dp)
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = selfSubtitle,
color = secondaryTextColor,
fontSize = 13.sp
)
} }
} }
}
if (!errorText.isNullOrBlank()) { Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = errorText ?: "", text = "After creating the group, you can invite as many users as you need.",
color = MaterialTheme.colorScheme.error color = secondaryTextColor,
fontSize = 13.sp,
lineHeight = 18.sp
) )
} } else {
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp)) Row(
Row(modifier = Modifier.fillMaxWidth()) { modifier =
Text( Modifier
text = .fillMaxWidth()
if (selectedTab == 0) { .clip(RoundedCornerShape(14.dp))
"Creates a new private group and joins it automatically." .background(cardColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(50.dp).clip(CircleShape).background(accentColor.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
if (!selectedAvatarUri.isNullOrBlank()) {
AsyncImage(
model =
ImageRequest.Builder(context)
.data(Uri.parse(selectedAvatarUri))
.crossfade(true)
.build(),
contentDescription = "Group avatar preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else { } else {
"Paste a full invite string that starts with #group:." Icon(
}, painter = TelegramIcons.AddPhoto,
style = MaterialTheme.typography.bodySmall, contentDescription = null,
color = MaterialTheme.colorScheme.onSurfaceVariant tint = accentColor
)
}
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title.trim(),
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Add a description (optional)",
color = secondaryTextColor,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Description",
color = secondaryTextColor,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
modifier = Modifier.padding(bottom = 8.dp)
) )
TextField(
value = description,
onValueChange = { newValue -> description = newValue.take(400) },
modifier =
Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(14.dp))
.background(sectionColor),
minLines = 6,
maxLines = 8,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = primaryTextColor, fontSize = 16.sp),
placeholder = {
Text(
text = "Group description",
color = secondaryTextColor,
fontSize = 16.sp
)
},
colors =
TextFieldDefaults.colors(
focusedTextColor = primaryTextColor,
unfocusedTextColor = primaryTextColor,
focusedContainerColor = sectionColor,
unfocusedContainerColor = sectionColor,
disabledContainerColor = sectionColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedPlaceholderColor = secondaryTextColor,
unfocusedPlaceholderColor = secondaryTextColor,
cursorColor = accentColor
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Description is optional. You can change it later in group info.",
color = secondaryTextColor,
fontSize = 13.sp,
textAlign = TextAlign.Start
)
}
if (!errorText.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = errorText.orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
FloatingActionButton(
onClick = {
if (step == GroupSetupStep.DETAILS) {
if (canGoNext) {
errorText = null
step = GroupSetupStep.DESCRIPTION
}
return@FloatingActionButton
}
if (!canCreate) return@FloatingActionButton
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
withContext(Dispatchers.IO) {
persistLocalGroupAvatar(
context = context,
avatarRepository = avatarRepository,
groupDialogPublicKey = result.dialogPublicKey,
avatarUriString = selectedAvatarUri
)
}
openGroup(
dialogPublicKey = result.dialogPublicKey,
groupTitle = result.title.ifBlank { title.trim() }
)
} else {
errorText = mapError(result.status, result.error ?: "Cannot create group")
}
isLoading = false
}
},
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
contentColor = Color.White,
shape = CircleShape,
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = fabBottomPadding)
.size(58.dp)
) {
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(22.dp)
)
} else {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Continue"
)
}
} }
} }
} }
ProfilePhotoPicker(
isVisible = showPhotoPicker,
onDismiss = { showPhotoPicker = false },
onPhotoSelected = { uri ->
showPhotoPicker = false
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
cropLauncher.launch(cropIntent)
},
isDarkTheme = isDarkTheme
)
}
private suspend fun persistLocalGroupAvatar(
context: android.content.Context,
avatarRepository: AvatarRepository?,
groupDialogPublicKey: String,
avatarUriString: String?
) {
val repository = avatarRepository ?: return
val safeUri = avatarUriString?.takeIf { it.isNotBlank() } ?: return
val uri = runCatching { Uri.parse(safeUri) }.getOrNull() ?: return
val rawBytes =
runCatching {
context.contentResolver.openInputStream(uri)?.use { stream -> stream.readBytes() }
}.getOrNull() ?: return
if (rawBytes.isEmpty()) return
val preparedBase64 = AvatarFileManager.imagePrepareForNetworkTransfer(context, rawBytes)
if (preparedBase64.isBlank()) return
repository.saveAvatar(
fromPublicKey = groupDialogPublicKey,
base64Image = "data:image/png;base64,$preparedBase64"
)
}
private fun decodeEmojiCodeToUnicode(value: String): String {
val match = Regex("^:emoji_([a-fA-F0-9_-]+):$").matchEntire(value) ?: return value
val codePoints =
match.groupValues[1]
.split("-")
.mapNotNull { code -> code.toIntOrNull(16) }
if (codePoints.isEmpty()) return value
return String(codePoints.toIntArray(), 0, codePoints.size)
}
private fun shortPublicKey(publicKey: String): String {
val normalized = publicKey.trim()
return if (normalized.length <= 12) normalized else "${normalized.take(6)}...${normalized.takeLast(4)}"
} }

View File

@@ -122,6 +122,8 @@ internal fun CameraGridItem(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
val iconScale = remember { Animatable(0f) } val iconScale = remember { Animatable(0f) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -145,6 +147,21 @@ internal fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
DisposableEffect(lifecycleOwner, hasCameraPermission) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
if (provider != null && preview != null) {
try {
provider.unbind(preview)
} catch (_: Exception) {
}
}
previewUseCase = null
cameraProvider = null
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
@@ -164,17 +181,24 @@ internal fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
try { try {
val cameraProvider = cameraProviderFuture.get() val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also { val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider) it.setSurfaceProvider(previewView.surfaceProvider)
} }
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll() previewUseCase?.let { existing ->
cameraProvider.bindToLifecycle( try {
provider.unbind(existing)
} catch (_: Exception) {
}
}
provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
cameraSelector, cameraSelector,
preview preview
) )
previewUseCase = preview
} catch (_: Exception) { } catch (_: Exception) {
// Camera init failed // Camera init failed
} }

View File

@@ -440,6 +440,17 @@ fun ChatAttachAlert(
} else { } else {
requestPermissions() requestPermissions()
} }
} else if (shouldShow || isClosing) {
// Parent hidden externally (e.g. app background): force-close immediately.
// This guarantees camera preview does not stay active across app reopen.
pendingCaptionFocus = false
captionInputActive = false
showEmojiPicker = false
coordinator.isEmojiVisible = false
coordinator.isEmojiBoxVisible = false
isClosing = false
shouldShow = false
showAlbumMenu = false
} }
} }

View File

@@ -1668,6 +1668,8 @@ private fun CameraGridItem(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
// Bounce animation for camera icon // Bounce animation for camera icon
val iconScale = remember { Animatable(0f) } val iconScale = remember { Animatable(0f) }
@@ -1698,6 +1700,21 @@ private fun CameraGridItem(
Manifest.permission.CAMERA Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED ) == PackageManager.PERMISSION_GRANTED
} }
DisposableEffect(lifecycleOwner, hasCameraPermission) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
if (provider != null && preview != null) {
try {
provider.unbind(preview)
} catch (_: Exception) {
}
}
previewUseCase = null
cameraProvider = null
}
}
Box( Box(
modifier = Modifier modifier = Modifier
@@ -1719,7 +1736,8 @@ private fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
try { try {
val cameraProvider = cameraProviderFuture.get() val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also { val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider) it.setSurfaceProvider(previewView.surfaceProvider)
@@ -1728,12 +1746,18 @@ private fun CameraGridItem(
// Use back camera // Use back camera
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll() previewUseCase?.let { existing ->
cameraProvider.bindToLifecycle( try {
provider.unbind(existing)
} catch (_: Exception) {
}
}
provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
cameraSelector, cameraSelector,
preview preview
) )
previewUseCase = preview
} catch (e: Exception) { } catch (e: Exception) {
// Camera init failed // Camera init failed
} }