feat: enhance camera handling and UI state management in chat components
This commit is contained in:
@@ -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 =
|
||||||
|
|||||||
@@ -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 -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user