From 8f7544c6558e2774550e55316cae4f7b3254651e Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Mon, 2 Mar 2026 14:07:42 +0500 Subject: [PATCH] feat: enhance camera handling and UI state management in chat components --- .../com/rosetta/messenger/MainActivity.kt | 3 + .../messenger/ui/chats/ChatDetailScreen.kt | 8 + .../messenger/ui/chats/GroupSetupScreen.kt | 757 ++++++++++++++---- .../ui/chats/attach/AttachAlertComponents.kt | 30 +- .../ui/chats/attach/ChatAttachAlert.kt | 11 + .../components/MediaPickerBottomSheet.kt | 30 +- 6 files changed, 691 insertions(+), 148 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 5ae24b4..7266ef2 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1072,6 +1072,9 @@ fun MainScreen( isDarkTheme = isDarkTheme, accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey, + accountName = accountName, + accountUsername = accountUsername, + avatarRepository = avatarRepository, onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } }, onGroupOpened = { groupUser -> navStack = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 6b709de..81b9f00 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -633,6 +633,14 @@ fun ChatDetailScreen( isScreenActive = 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 -> {} } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 6b7ec67..eb0b529 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -1,61 +1,175 @@ 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.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.size +import androidx.compose.foundation.shape.CircleShape 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.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.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow 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.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Alignment 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.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.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.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.network.GroupStatus 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.launch import kotlinx.coroutines.withContext -@OptIn(ExperimentalMaterial3Api::class) +private enum class GroupSetupStep { + DETAILS, + DESCRIPTION +} + @Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) fun GroupSetupScreen( isDarkTheme: Boolean, accountPublicKey: String, accountPrivateKey: String, + accountName: String, + accountUsername: String, + avatarRepository: AvatarRepository? = null, onBack: () -> Unit, onGroupOpened: (SearchUser) -> Unit ) { val scope = rememberCoroutineScope() 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 title by remember { mutableStateOf("") } - var description by remember { mutableStateOf("") } - var inviteString by remember { mutableStateOf("") } - var isLoading by remember { mutableStateOf(false) } - var errorText by remember { mutableStateOf(null) } + var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) } + var title by rememberSaveable { mutableStateOf("") } + var description by rememberSaveable { mutableStateOf("") } + var selectedAvatarUri by rememberSaveable { mutableStateOf(null) } + var isLoading by rememberSaveable { mutableStateOf(false) } + var errorText by rememberSaveable { mutableStateOf(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) { onGroupOpened( @@ -77,13 +191,6 @@ fun GroupSetupScreen( description = description.trim() ) - suspend fun joinGroup() = - GroupRepository.getInstance(context).joinGroup( - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey, - inviteString = inviteString.trim() - ) - fun mapError(status: GroupStatus, fallback: String): String { return when (status) { 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( + containerColor = backgroundColor, topBar = { 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 = { - TextButton(onClick = onBack) { - Text("Back") + IconButton(onClick = ::handleBack) { + 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 -> - Column( + Box( modifier = Modifier .fillMaxSize() .padding(paddingValues) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.Top ) { - TabRow(selectedTabIndex = selectedTab) { - Tab( - selected = selectedTab == 0, - onClick = { - selectedTab = 0 - errorText = null - }, - text = { Text("Create") } - ) - 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 - ) + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .navigationBarsPadding() + ) { + if (step == GroupSetupStep.DETAILS) { Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - 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 - } - }, + + Row( modifier = Modifier.fillMaxWidth(), - enabled = title.trim().isNotEmpty() && !isLoading + verticalAlignment = Alignment.CenterVertically ) { - if (isLoading) { - CircularProgressIndicator(strokeWidth = 2.dp) - } else { - Text("Create Group") + Box( + contentAlignment = Alignment.Center, + modifier = + 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( - value = inviteString, - onValueChange = { inviteString = it }, - label = { Text("Invite string") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 6, - shape = RoundedCornerShape(12.dp) + + Spacer(modifier = Modifier.height(22.dp)) + + Text( + text = "1 member", + color = accentColor, + fontWeight = FontWeight.Medium, + fontSize = 15.sp, + modifier = Modifier.padding(bottom = 0.dp) ) - Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { - if (isLoading) return@Button - errorText = null - isLoading = true - scope.launch { - val result = withContext(Dispatchers.IO) { joinGroup() } - if (result.success && !result.dialogPublicKey.isNullOrBlank()) { - openGroup(result.dialogPublicKey, result.title) - } else { - errorText = - mapError( - result.status, - result.error ?: "Cannot join group" - ) - } - isLoading = false - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = inviteString.trim().isNotEmpty() && !isLoading + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(sectionColor) + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { - if (isLoading) { - CircularProgressIndicator(strokeWidth = 2.dp) - } else { - Text("Join Group") + AvatarImage( + publicKey = accountPublicKey, + avatarRepository = avatarRepository, + 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(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) Text( - text = errorText ?: "", - color = MaterialTheme.colorScheme.error + text = "After creating the group, you can invite as many users as you need.", + color = secondaryTextColor, + fontSize = 13.sp, + lineHeight = 18.sp ) - } + } else { + Spacer(modifier = Modifier.height(16.dp)) - Spacer(modifier = Modifier.height(16.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - Text( - text = - if (selectedTab == 0) { - "Creates a new private group and joins it automatically." + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .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 { - "Paste a full invite string that starts with #group:." - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + Icon( + painter = TelegramIcons.AddPhoto, + contentDescription = null, + 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)}" } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt index 67a61e4..d082f72 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt @@ -122,6 +122,8 @@ internal fun CameraGridItem( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + var cameraProvider by remember { mutableStateOf(null) } + var previewUseCase by remember { mutableStateOf(null) } val iconScale = remember { Animatable(0f) } LaunchedEffect(Unit) { @@ -145,6 +147,21 @@ internal fun CameraGridItem( ) == 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( modifier = Modifier .aspectRatio(1f) @@ -164,17 +181,24 @@ internal fun CameraGridItem( val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) cameraProviderFuture.addListener({ try { - val cameraProvider = cameraProviderFuture.get() + val provider = cameraProviderFuture.get() + cameraProvider = provider val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) } val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( + previewUseCase?.let { existing -> + try { + provider.unbind(existing) + } catch (_: Exception) { + } + } + provider.bindToLifecycle( lifecycleOwner, cameraSelector, preview ) + previewUseCase = preview } catch (_: Exception) { // Camera init failed } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index f40b504..302276e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -440,6 +440,17 @@ fun ChatAttachAlert( } else { 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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index a050c4b..d9e9637 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -1668,6 +1668,8 @@ private fun CameraGridItem( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + var cameraProvider by remember { mutableStateOf(null) } + var previewUseCase by remember { mutableStateOf(null) } // Bounce animation for camera icon val iconScale = remember { Animatable(0f) } @@ -1698,6 +1700,21 @@ private fun CameraGridItem( Manifest.permission.CAMERA ) == 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( modifier = Modifier @@ -1719,7 +1736,8 @@ private fun CameraGridItem( val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx) cameraProviderFuture.addListener({ try { - val cameraProvider = cameraProviderFuture.get() + val provider = cameraProviderFuture.get() + cameraProvider = provider val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) @@ -1728,12 +1746,18 @@ private fun CameraGridItem( // Use back camera val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( + previewUseCase?.let { existing -> + try { + provider.unbind(existing) + } catch (_: Exception) { + } + } + provider.bindToLifecycle( lifecycleOwner, cameraSelector, preview ) + previewUseCase = preview } catch (e: Exception) { // Camera init failed }