feat: enhance camera handling and UI state management in chat components
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var description by rememberSaveable { mutableStateOf("") }
|
||||
var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var isLoading by rememberSaveable { mutableStateOf(false) }
|
||||
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) {
|
||||
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)}"
|
||||
}
|
||||
|
||||
@@ -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<ProcessCameraProvider?>(null) }
|
||||
var previewUseCase by remember { mutableStateOf<Preview?>(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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ProcessCameraProvider?>(null) }
|
||||
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
|
||||
|
||||
// Bounce animation for camera icon
|
||||
val iconScale = remember { Animatable(0f) }
|
||||
@@ -1699,6 +1701,21 @@ private 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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user