feat: Implement pinch-to-zoom functionality in InAppCameraScreen and add zoom controls
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.0.4"
|
val rosettaVersionName = "1.0.5"
|
||||||
val rosettaVersionCode = 4 // Increment on each release
|
val rosettaVersionCode = 5 // Increment on each release
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
- New attachment panel with tabs (photos, files, avatar)
|
- Emoji keyboard in attachment panel with smooth transitions
|
||||||
- Message drafts — text is saved when leaving a chat
|
- File browser — send any file from device storage
|
||||||
- Circular reveal animation for theme switching
|
- Send gallery photos as files (without compression)
|
||||||
- Photo albums in media picker
|
- Camera: pinch-to-zoom, zoom slider, transparent status bar
|
||||||
- Camera flash mode is now saved between sessions
|
- Double checkmarks for read photos, files and avatars
|
||||||
- Swipe-back gesture fixes
|
- Haptic feedback on swipe-to-reply
|
||||||
- UI and performance improvements
|
- System accounts hidden from forward picker
|
||||||
|
- Smoother caption bar animations
|
||||||
|
- Bug fixes and performance improvements
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
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.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.R
|
||||||
import com.rosetta.messenger.data.ForwardManager
|
import com.rosetta.messenger.data.ForwardManager
|
||||||
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -65,6 +68,11 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
|
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
|
||||||
val messagesCount = forwardMessages.size
|
val messagesCount = forwardMessages.size
|
||||||
|
|
||||||
|
// 🔥 Фильтруем системные аккаунты (Safe, Rosetta Updates)
|
||||||
|
val filteredDialogs = remember(dialogs) {
|
||||||
|
dialogs.filter { !MessageRepository.isSystemAccount(it.opponentKey) }
|
||||||
|
}
|
||||||
|
|
||||||
// Мультивыбор чатов
|
// Мультивыбор чатов
|
||||||
var selectedChats by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var selectedChats by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
|
|
||||||
@@ -187,7 +195,7 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
Divider(color = dividerColor, thickness = 0.5.dp)
|
Divider(color = dividerColor, thickness = 0.5.dp)
|
||||||
|
|
||||||
// Список диалогов
|
// Список диалогов
|
||||||
if (dialogs.isEmpty()) {
|
if (filteredDialogs.isEmpty()) {
|
||||||
// Empty state
|
// Empty state
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().height(200.dp),
|
modifier = Modifier.fillMaxWidth().height(200.dp),
|
||||||
@@ -214,7 +222,7 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
items(dialogs, key = { it.opponentKey }) { dialog ->
|
items(filteredDialogs, key = { it.opponentKey }) { dialog ->
|
||||||
val isSelected = dialog.opponentKey in selectedChats
|
val isSelected = dialog.opponentKey in selectedChats
|
||||||
ForwardDialogItem(
|
ForwardDialogItem(
|
||||||
dialog = dialog,
|
dialog = dialog,
|
||||||
@@ -233,7 +241,7 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Сепаратор между диалогами
|
// Сепаратор между диалогами
|
||||||
if (dialog != dialogs.last()) {
|
if (dialog != filteredDialogs.last()) {
|
||||||
Divider(
|
Divider(
|
||||||
modifier = Modifier.padding(start = 76.dp),
|
modifier = Modifier.padding(start = 76.dp),
|
||||||
color = dividerColor,
|
color = dividerColor,
|
||||||
@@ -252,7 +260,7 @@ fun ForwardChatPickerBottomSheet(
|
|||||||
val hasSelection = selectedChats.isNotEmpty()
|
val hasSelection = selectedChats.isNotEmpty()
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
val selectedDialogs = dialogs.filter { it.opponentKey in selectedChats }
|
val selectedDialogs = filteredDialogs.filter { it.opponentKey in selectedChats }
|
||||||
onChatsSelected(selectedDialogs)
|
onChatsSelected(selectedDialogs)
|
||||||
},
|
},
|
||||||
enabled = hasSelection,
|
enabled = hasSelection,
|
||||||
@@ -339,14 +347,14 @@ private fun ForwardDialogItem(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(PrimaryBlue.copy(alpha = 0.15f)),
|
.background(PrimaryBlue),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
text = "📁",
|
painter = painterResource(R.drawable.bookmark_outlined),
|
||||||
color = PrimaryBlue,
|
contentDescription = null,
|
||||||
fontWeight = FontWeight.SemiBold,
|
tint = Color.White,
|
||||||
fontSize = 16.sp
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -784,91 +784,103 @@ internal fun CaptionBar(
|
|||||||
captionText: String,
|
captionText: String,
|
||||||
onCaptionChange: (String) -> Unit,
|
onCaptionChange: (String) -> Unit,
|
||||||
isCaptionKeyboardVisible: Boolean,
|
isCaptionKeyboardVisible: Boolean,
|
||||||
|
isEmojiVisible: Boolean = false,
|
||||||
onToggleKeyboard: () -> Unit,
|
onToggleKeyboard: () -> Unit,
|
||||||
onFocusCaption: () -> Unit,
|
onFocusCaption: () -> Unit,
|
||||||
onViewCreated: (com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit,
|
onViewCreated: (com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
density: androidx.compose.ui.unit.Density
|
density: androidx.compose.ui.unit.Density
|
||||||
) {
|
) {
|
||||||
val captionBarHeight = 52.dp * captionBarProgress
|
// Telegram-style CaptionBar:
|
||||||
Box(
|
// - AnimatedVisibility for show/hide (expandVertically + fadeIn/Out, 180ms)
|
||||||
modifier = Modifier
|
// - animateContentSize for smooth multiline expansion (200ms CubicBezier,
|
||||||
.fillMaxWidth()
|
// matching Telegram's captionEditTextTopOffset animation)
|
||||||
.height(captionBarHeight)
|
val captionMultilineEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
|
||||||
.graphicsLayer { clip = true }
|
val captionLiftEasing = CubicBezierEasing(0f, 0f, 0.2f, 1f)
|
||||||
|
val isVisible = captionBarProgress > 0.001f
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = expandVertically(
|
||||||
|
animationSpec = tween(180, easing = captionLiftEasing),
|
||||||
|
expandFrom = Alignment.Bottom
|
||||||
|
) + fadeIn(animationSpec = tween(180, easing = captionLiftEasing)),
|
||||||
|
exit = shrinkVertically(
|
||||||
|
animationSpec = tween(180, easing = captionLiftEasing),
|
||||||
|
shrinkTowards = Alignment.Bottom
|
||||||
|
) + fadeOut(animationSpec = tween(180, easing = captionLiftEasing))
|
||||||
) {
|
) {
|
||||||
if (captionBarProgress > 0.001f) {
|
Column(
|
||||||
val captionOffsetPx = with(density) { (1f - captionBarProgress) * 18.dp.toPx() }
|
modifier = Modifier
|
||||||
Column(
|
.fillMaxWidth()
|
||||||
|
// animateContentSize: smooth multiline expansion/collapse
|
||||||
|
// like Telegram's captionEditTextTopOffset (200ms CubicBezier)
|
||||||
|
.animateContentSize(
|
||||||
|
animationSpec = tween(200, easing = captionMultilineEasing)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Thin divider
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.graphicsLayer {
|
.height(0.5.dp)
|
||||||
alpha = captionBarProgress
|
.background(
|
||||||
translationY = captionOffsetPx
|
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
|
||||||
}
|
else Color.Black.copy(alpha = 0.12f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Flat caption row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 6.dp, end = 84.dp, top = 4.dp, bottom = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
// Thin divider
|
// Emoji visible → show Keyboard icon (to switch back)
|
||||||
|
// Keyboard visible → show Smile icon (to switch to emoji)
|
||||||
|
// Neither → show Smile icon (to open keyboard on first tap)
|
||||||
|
val captionIconPainter =
|
||||||
|
if (isEmojiVisible) TelegramIcons.Keyboard
|
||||||
|
else TelegramIcons.Smile
|
||||||
|
Icon(
|
||||||
|
painter = captionIconPainter,
|
||||||
|
contentDescription = "Caption keyboard toggle",
|
||||||
|
tint = if (isDarkTheme) Color.White.copy(alpha = 0.55f) else Color.Gray,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) { onToggleKeyboard() }
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.weight(1f)
|
||||||
.height(0.5.dp)
|
.heightIn(min = 28.dp, max = 120.dp),
|
||||||
.background(
|
contentAlignment = Alignment.CenterStart
|
||||||
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
|
|
||||||
else Color.Black.copy(alpha = 0.12f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
// Flat caption row
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 6.dp, end = 84.dp, top = 4.dp, bottom = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
) {
|
||||||
val captionIconPainter =
|
AppleEmojiTextField(
|
||||||
if (isCaptionKeyboardVisible) TelegramIcons.Keyboard
|
value = captionText,
|
||||||
else TelegramIcons.Smile
|
onValueChange = onCaptionChange,
|
||||||
Icon(
|
textColor = if (isDarkTheme) Color.White else Color.Black,
|
||||||
painter = captionIconPainter,
|
textSize = 16f,
|
||||||
contentDescription = "Caption keyboard toggle",
|
hint = "Add a caption...",
|
||||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.55f) else Color.Gray,
|
hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.35f)
|
||||||
modifier = Modifier
|
else Color.Gray.copy(alpha = 0.5f),
|
||||||
.size(32.dp)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.clip(CircleShape)
|
requestFocus = false,
|
||||||
.clickable(
|
onViewCreated = onViewCreated,
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
onFocusChanged = { hasFocus ->
|
||||||
indication = null
|
// When EditText gets native touch → gains focus →
|
||||||
) { onToggleKeyboard() }
|
// trigger FLAG_ALT_FOCUSABLE_IM management in ChatAttachAlert.
|
||||||
.padding(4.dp)
|
android.util.Log.d("AttachAlert", "CaptionBar EditText onFocusChanged: hasFocus=$hasFocus")
|
||||||
|
if (hasFocus) onFocusCaption()
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.heightIn(min = 28.dp, max = 120.dp),
|
|
||||||
contentAlignment = Alignment.CenterStart
|
|
||||||
) {
|
|
||||||
AppleEmojiTextField(
|
|
||||||
value = captionText,
|
|
||||||
onValueChange = onCaptionChange,
|
|
||||||
textColor = if (isDarkTheme) Color.White else Color.Black,
|
|
||||||
textSize = 16f,
|
|
||||||
hint = "Add a caption...",
|
|
||||||
hintColor = if (isDarkTheme) Color.White.copy(alpha = 0.35f)
|
|
||||||
else Color.Gray.copy(alpha = 0.5f),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
requestFocus = false,
|
|
||||||
onViewCreated = onViewCreated,
|
|
||||||
onFocusChanged = { hasFocus ->
|
|
||||||
// When EditText gets native touch → gains focus →
|
|
||||||
// trigger FLAG_ALT_FOCUSABLE_IM management in ChatAttachAlert.
|
|
||||||
// This replaces the previous .clickable on parent Box which
|
|
||||||
// swallowed touch events and prevented keyboard from appearing.
|
|
||||||
android.util.Log.d("AttachAlert", "CaptionBar EditText onFocusChanged: hasFocus=$hasFocus")
|
|
||||||
if (hasFocus) onFocusCaption()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
|
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
|
||||||
import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen
|
import com.rosetta.messenger.ui.chats.components.PhotoPreviewWithCaptionScreen
|
||||||
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
|
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
|
||||||
|
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||||
|
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
|
||||||
|
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
||||||
|
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||||
|
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -103,10 +108,11 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
|
|||||||
} else {
|
} else {
|
||||||
params.flags = params.flags or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
params.flags = params.flags or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
|
||||||
}
|
}
|
||||||
// Don't change softInputMode on the Popup window.
|
// Telegram approach: SOFT_INPUT_ADJUST_NOTHING on the Popup window itself.
|
||||||
// Compose Popup + enableEdgeToEdge() handles keyboard insets via WindowInsets
|
// This prevents the system from resizing or panning the Popup when keyboard
|
||||||
// (no physical window resize). Manually changing to ADJUST_RESIZE causes
|
// opens. The sheet stays at the same height — we handle keyboard space
|
||||||
// double-subtraction: system shrinks window AND we subtract imeInsets.
|
// internally via a bottom spacer (like Telegram's AdjustPanLayoutHelper).
|
||||||
|
params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
|
||||||
try {
|
try {
|
||||||
wm.updateViewLayout(rootView, params)
|
wm.updateViewLayout(rootView, params)
|
||||||
AttachAlertDebugLog.log("AttachAlert", "updatePopupImeFocusable($imeFocusable) — flags updated OK")
|
AttachAlertDebugLog.log("AttachAlert", "updatePopupImeFocusable($imeFocusable) — flags updated OK")
|
||||||
@@ -166,6 +172,10 @@ fun ChatAttachAlert(
|
|||||||
var pendingDismissAfterConfirm by remember { mutableStateOf<(() -> Unit)?>(null) }
|
var pendingDismissAfterConfirm by remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
var hadSelection by remember { mutableStateOf(false) }
|
var hadSelection by remember { mutableStateOf(false) }
|
||||||
var isCaptionKeyboardVisible by remember { mutableStateOf(false) }
|
var isCaptionKeyboardVisible by remember { mutableStateOf(false) }
|
||||||
|
var showEmojiPicker by remember { mutableStateOf(false) }
|
||||||
|
val coordinator = rememberKeyboardTransitionCoordinator()
|
||||||
|
var lastToggleTime by remember { mutableLongStateOf(0L) }
|
||||||
|
val toggleCooldownMs = 500L
|
||||||
var showAlbumMenu by remember { mutableStateOf(false) }
|
var showAlbumMenu by remember { mutableStateOf(false) }
|
||||||
var imeBottomInsetPx by remember { mutableIntStateOf(0) }
|
var imeBottomInsetPx by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
@@ -189,6 +199,9 @@ fun ChatAttachAlert(
|
|||||||
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
|
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
|
||||||
pendingCaptionFocus = false
|
pendingCaptionFocus = false
|
||||||
captionInputActive = false
|
captionInputActive = false
|
||||||
|
showEmojiPicker = false
|
||||||
|
coordinator.isEmojiVisible = false
|
||||||
|
coordinator.isEmojiBoxVisible = false
|
||||||
focusManager.clearFocus(force = true)
|
focusManager.clearFocus(force = true)
|
||||||
captionEditTextView?.clearFocus()
|
captionEditTextView?.clearFocus()
|
||||||
|
|
||||||
@@ -218,6 +231,61 @@ fun ChatAttachAlert(
|
|||||||
pendingCaptionFocus = true
|
pendingCaptionFocus = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram-style emoji toggle using KeyboardTransitionCoordinator
|
||||||
|
fun toggleEmojiPicker() {
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
if (currentTime - lastToggleTime < toggleCooldownMs) return
|
||||||
|
lastToggleTime = currentTime
|
||||||
|
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
|
||||||
|
if (coordinator.isEmojiVisible) {
|
||||||
|
// EMOJI → KEYBOARD
|
||||||
|
coordinator.requestShowKeyboard(
|
||||||
|
showKeyboard = {
|
||||||
|
captionInputActive = true
|
||||||
|
popupRootView?.let { updatePopupImeFocusable(it, true) }
|
||||||
|
captionEditTextView?.let { editText ->
|
||||||
|
editText.requestFocus()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hideEmoji = { showEmojiPicker = false }
|
||||||
|
)
|
||||||
|
} else if (isCaptionKeyboardVisible || imeBottomInsetPx > 0) {
|
||||||
|
// KEYBOARD → EMOJI
|
||||||
|
coordinator.requestShowEmoji(
|
||||||
|
hideKeyboard = {
|
||||||
|
captionEditTextView?.windowToken?.let { token ->
|
||||||
|
imm.hideSoftInputFromWindow(token, 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showEmoji = { showEmojiPicker = true }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Nothing visible → open emoji directly
|
||||||
|
if (coordinator.emojiHeight == 0.dp) {
|
||||||
|
// Use saved keyboard height minus nav bar (same as spacer)
|
||||||
|
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
|
||||||
|
val navBarPx = (context as? Activity)?.window?.decorView?.let { view ->
|
||||||
|
androidx.core.view.ViewCompat.getRootWindowInsets(view)
|
||||||
|
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
|
||||||
|
} ?: 0
|
||||||
|
val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0
|
||||||
|
coordinator.emojiHeight = if (effectivePx > 0) {
|
||||||
|
with(density) { effectivePx.toDp() }
|
||||||
|
} else {
|
||||||
|
260.dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coordinator.isEmojiBoxVisible = true
|
||||||
|
coordinator.openEmojiOnly(
|
||||||
|
showEmoji = { showEmojiPicker = true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Layout metrics
|
// Layout metrics
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
@@ -233,7 +301,9 @@ fun ChatAttachAlert(
|
|||||||
(screenHeightPx + statusBarInsetPx).coerceAtLeast(screenHeightPx * 0.95f)
|
(screenHeightPx + statusBarInsetPx).coerceAtLeast(screenHeightPx * 0.95f)
|
||||||
val minHeightPx = screenHeightPx * 0.2f
|
val minHeightPx = screenHeightPx * 0.2f
|
||||||
val captionLiftEasing = CubicBezierEasing(0f, 0f, 0.2f, 1f)
|
val captionLiftEasing = CubicBezierEasing(0f, 0f, 0.2f, 1f)
|
||||||
val telegramButtonsEasing = CubicBezierEasing(0f, 0f, 0.58f, 1f)
|
// Telegram uses DecelerateInterpolator for all showCommentTextView animations.
|
||||||
|
// CubicBezier(0,0,0.2,1) approximates DecelerateInterpolator — unify to one easing.
|
||||||
|
val telegramButtonsEasing = captionLiftEasing
|
||||||
val collapsedStateHeightPx = collapsedHeightPx.coerceAtMost(expandedHeightPx)
|
val collapsedStateHeightPx = collapsedHeightPx.coerceAtMost(expandedHeightPx)
|
||||||
val selectionHeaderExtraPx = with(density) { 26.dp.toPx() }
|
val selectionHeaderExtraPx = with(density) { 26.dp.toPx() }
|
||||||
|
|
||||||
@@ -607,14 +677,12 @@ fun ChatAttachAlert(
|
|||||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
insetsController?.isAppearanceLightStatusBars = !dark
|
insetsController?.isAppearanceLightStatusBars = !dark
|
||||||
} else {
|
} else {
|
||||||
window.statusBarColor = android.graphics.Color.argb(
|
// Apply scrim to status bar so it matches the overlay darkness
|
||||||
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
|
val scrimInt = (alpha * 255).toInt().coerceIn(0, 255)
|
||||||
)
|
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
|
||||||
insetsController?.isAppearanceLightStatusBars = false
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
}
|
}
|
||||||
window.navigationBarColor = android.graphics.Color.argb(
|
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||||
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
|
|
||||||
)
|
|
||||||
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
|
insetsController?.isAppearanceLightNavigationBars = alpha < 0.15f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,26 +754,66 @@ fun ChatAttachAlert(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telegram approach: the sheet height NEVER changes for keyboard.
|
||||||
|
// We compute the keyboard height only for a bottom Spacer inside the Column.
|
||||||
val keyboardInsetPx =
|
val keyboardInsetPx =
|
||||||
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
|
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
|
||||||
// Only shrink the sheet for keyboard when caption input is active.
|
val keyboardSpacerPxRaw =
|
||||||
// When FLAG_ALT_FOCUSABLE_IM is set, keyboard belongs to chat input behind picker.
|
|
||||||
val targetKeyboardInsetPx =
|
|
||||||
if (state.selectedItemOrder.isNotEmpty() && captionInputActive && !isClosing) keyboardInsetPx else 0f
|
if (state.selectedItemOrder.isNotEmpty() && captionInputActive && !isClosing) keyboardInsetPx else 0f
|
||||||
// Smooth 250ms animation matching Telegram's AdjustPanLayoutHelper duration.
|
|
||||||
// This interpolates the keyboard offset so the sheet slides up/down smoothly
|
|
||||||
// instead of jumping instantly when the IME appears/disappears.
|
|
||||||
val animatedKeyboardInsetPx by animateFloatAsState(
|
|
||||||
targetValue = targetKeyboardInsetPx,
|
|
||||||
animationSpec = tween(250, easing = FastOutSlowInEasing),
|
|
||||||
label = "keyboard_inset_anim"
|
|
||||||
)
|
|
||||||
val navBarDp = with(density) { navigationBarInsetPx.toDp() }
|
|
||||||
|
|
||||||
// Log sheet geometry on every recomposition where values are interesting
|
// Track whether emoji box was previously visible to detect the moment it disappears
|
||||||
if (imeBottomInsetPx > 0 || captionInputActive) {
|
val prevEmojiBoxVisible = remember { mutableStateOf(false) }
|
||||||
AttachAlertDebugLog.log("AttachAlert", "GEOM: imePx=$imeBottomInsetPx, navBarPx=${navigationBarInsetPx.toInt()}, kbInsetPx=${keyboardInsetPx.toInt()}, targetKbPx=${targetKeyboardInsetPx.toInt()}, animatedKbPx=${animatedKeyboardInsetPx.toInt()}, sheetPx=${sheetHeightPx.value.toInt()}, captionActive=$captionInputActive, selected=${state.selectedItemOrder.size}, shouldShow=$shouldShow, isClosing=$isClosing")
|
val emojiBoxCurrentlyVisible = coordinator.isEmojiBoxVisible
|
||||||
|
val justDismissedEmoji = prevEmojiBoxVisible.value && !emojiBoxCurrentlyVisible
|
||||||
|
|
||||||
|
// Update tracking ASAP
|
||||||
|
SideEffect { prevEmojiBoxVisible.value = emojiBoxCurrentlyVisible }
|
||||||
|
|
||||||
|
// Smooth spring animation on the spacer for a fluid keyboard transition
|
||||||
|
// BUT: snap instantly when emoji box disappears (to avoid input dropping)
|
||||||
|
val keyboardSpacerAnim = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(keyboardSpacerPxRaw, emojiBoxCurrentlyVisible, justDismissedEmoji) {
|
||||||
|
if (justDismissedEmoji) {
|
||||||
|
// Emoji box just disappeared → snap spacer to current keyboard height
|
||||||
|
keyboardSpacerAnim.snapTo(keyboardSpacerPxRaw)
|
||||||
|
} else if (!emojiBoxCurrentlyVisible) {
|
||||||
|
// Normal case: animate with spring
|
||||||
|
keyboardSpacerAnim.animateTo(
|
||||||
|
keyboardSpacerPxRaw,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = 400f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// When emoji box is visible, spacer is not rendered—no animation needed
|
||||||
}
|
}
|
||||||
|
val keyboardSpacerPx = keyboardSpacerAnim.value
|
||||||
|
val keyboardSpacerDp = with(density) { keyboardSpacerPx.toDp() }
|
||||||
|
|
||||||
|
// Feed coordinator the same nav-bar-subtracted keyboard height as the spacer.
|
||||||
|
// This way emoji picker height == spacer height == exact keyboard visual area.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow {
|
||||||
|
with(density) {
|
||||||
|
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f).toDp()
|
||||||
|
}
|
||||||
|
}.collect { keyboardHeightDp ->
|
||||||
|
coordinator.updateKeyboardHeight(keyboardHeightDp)
|
||||||
|
if (keyboardHeightDp > 100.dp) {
|
||||||
|
coordinator.syncHeights()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute bottom padding for send button: emoji panel height or keyboard spacer
|
||||||
|
val bottomInputPadding = if (coordinator.isEmojiBoxVisible) {
|
||||||
|
coordinator.emojiHeight
|
||||||
|
} else keyboardSpacerDp
|
||||||
|
|
||||||
|
// When keyboard or emoji is open, nav bar is behind — don't pad for it
|
||||||
|
val navBarDp = if (keyboardSpacerPx > 0f || coordinator.isEmojiBoxVisible) 0.dp
|
||||||
|
else with(density) { navigationBarInsetPx.toDp() }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -718,8 +826,9 @@ fun ChatAttachAlert(
|
|||||||
.padding(bottom = navBarDp),
|
.padding(bottom = navBarDp),
|
||||||
contentAlignment = Alignment.BottomCenter
|
contentAlignment = Alignment.BottomCenter
|
||||||
) {
|
) {
|
||||||
val visibleSheetHeightPx =
|
// Sheet height stays constant — keyboard space is handled by
|
||||||
(sheetHeightPx.value - animatedKeyboardInsetPx).coerceAtLeast(minHeightPx)
|
// internal Spacer, not by shrinking the container (Telegram approach).
|
||||||
|
val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx)
|
||||||
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
|
||||||
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
|
||||||
val expandProgress =
|
val expandProgress =
|
||||||
@@ -936,15 +1045,39 @@ fun ChatAttachAlert(
|
|||||||
captionText = state.captionText,
|
captionText = state.captionText,
|
||||||
onCaptionChange = { viewModel.updateCaption(it) },
|
onCaptionChange = { viewModel.updateCaption(it) },
|
||||||
isCaptionKeyboardVisible = isCaptionKeyboardVisible,
|
isCaptionKeyboardVisible = isCaptionKeyboardVisible,
|
||||||
onToggleKeyboard = {
|
isEmojiVisible = showEmojiPicker,
|
||||||
if (isCaptionKeyboardVisible) hideKeyboard()
|
onToggleKeyboard = { toggleEmojiPicker() },
|
||||||
else focusCaptionInput()
|
|
||||||
},
|
|
||||||
onFocusCaption = { focusCaptionInput() },
|
onFocusCaption = { focusCaptionInput() },
|
||||||
onViewCreated = { captionEditTextView = it },
|
onViewCreated = { captionEditTextView = it },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
density = density
|
density = density
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Emoji Picker via AnimatedKeyboardTransition ──
|
||||||
|
AnimatedKeyboardTransition(
|
||||||
|
coordinator = coordinator,
|
||||||
|
showEmojiPicker = showEmojiPicker
|
||||||
|
) {
|
||||||
|
OptimizedEmojiPicker(
|
||||||
|
isVisible = true,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onEmojiSelected = { emoji ->
|
||||||
|
viewModel.updateCaption(state.captionText + emoji)
|
||||||
|
captionEditTextView?.let { editText ->
|
||||||
|
val newText = state.captionText + emoji
|
||||||
|
editText.setText(newText)
|
||||||
|
editText.setSelection(newText.length)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onClose = { toggleEmojiPicker() },
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard spacer (Telegram approach, only when emoji box not shown) ──
|
||||||
|
if (!coordinator.isEmojiBoxVisible) {
|
||||||
|
Spacer(modifier = Modifier.height(keyboardSpacerDp))
|
||||||
|
}
|
||||||
} // end Column
|
} // end Column
|
||||||
|
|
||||||
// ── Floating Send Button ──
|
// ── Floating Send Button ──
|
||||||
@@ -952,7 +1085,7 @@ fun ChatAttachAlert(
|
|||||||
val sendButtonVisible = state.selectedItemOrder.isNotEmpty()
|
val sendButtonVisible = state.selectedItemOrder.isNotEmpty()
|
||||||
val sendButtonProgress by animateFloatAsState(
|
val sendButtonProgress by animateFloatAsState(
|
||||||
targetValue = if (sendButtonVisible) 1f else 0f,
|
targetValue = if (sendButtonVisible) 1f else 0f,
|
||||||
animationSpec = tween(180, easing = FastOutSlowInEasing),
|
animationSpec = tween(180, easing = captionLiftEasing),
|
||||||
label = "send_button_progress"
|
label = "send_button_progress"
|
||||||
)
|
)
|
||||||
FloatingSendButton(
|
FloatingSendButton(
|
||||||
@@ -966,120 +1099,11 @@ fun ChatAttachAlert(
|
|||||||
onMediaSelected(selected, caption)
|
onMediaSelected(selected, caption)
|
||||||
animatedClose()
|
animatedClose()
|
||||||
},
|
},
|
||||||
modifier = Modifier.align(Alignment.BottomEnd)
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(bottom = bottomInputPadding)
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Debug Log Button ──
|
|
||||||
var showDebugLogs by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Small debug FAB in top-left corner
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.padding(start = 8.dp, top = 8.dp)
|
|
||||||
.size(28.dp)
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.background(Color.Red.copy(alpha = 0.7f))
|
|
||||||
.clickable { showDebugLogs = !showDebugLogs },
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text("L", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug log overlay
|
|
||||||
if (showDebugLogs) {
|
|
||||||
val logEntries = remember { mutableStateOf(AttachAlertDebugLog.entries) }
|
|
||||||
// Refresh logs periodically
|
|
||||||
LaunchedEffect(showDebugLogs) {
|
|
||||||
while (true) {
|
|
||||||
logEntries.value = AttachAlertDebugLog.entries
|
|
||||||
kotlinx.coroutines.delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.Center)
|
|
||||||
.fillMaxWidth(0.92f)
|
|
||||||
.fillMaxHeight(0.7f)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(Color.Black.copy(alpha = 0.92f))
|
|
||||||
.padding(8.dp)
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"AttachAlert Debug (${logEntries.value.size})",
|
|
||||||
color = Color.Green,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(
|
|
||||||
"CLEAR",
|
|
||||||
color = Color.Yellow,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(4.dp))
|
|
||||||
.clickable {
|
|
||||||
AttachAlertDebugLog.clear()
|
|
||||||
logEntries.value = emptyList()
|
|
||||||
}
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"✕",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable { showDebugLogs = false }
|
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.clip(RoundedCornerShape(6.dp))
|
|
||||||
.background(Color(0xFF1A1A1A))
|
|
||||||
) {
|
|
||||||
val logListState = rememberLazyListState()
|
|
||||||
LaunchedEffect(logEntries.value.size) {
|
|
||||||
if (logEntries.value.isNotEmpty()) {
|
|
||||||
logListState.animateScrollToItem(logEntries.value.size - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LazyColumn(
|
|
||||||
state = logListState,
|
|
||||||
modifier = Modifier.fillMaxSize().padding(6.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
|
||||||
items(logEntries.value) { entry ->
|
|
||||||
val entryColor = when {
|
|
||||||
entry.contains("[W/") -> Color(0xFFFF9800)
|
|
||||||
entry.contains("result=true") || entry.contains("OK") -> Color(0xFF4CAF50)
|
|
||||||
entry.contains("result=false") || entry.contains("failed") || entry.contains("NULL") -> Color(0xFFFF5252)
|
|
||||||
else -> Color(0xFFB0B0B0)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = entry,
|
|
||||||
color = entryColor,
|
|
||||||
fontSize = 10.sp,
|
|
||||||
lineHeight = 13.sp,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} // end Box sheet container
|
} // end Box sheet container
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1267,12 +1267,20 @@ fun ImageAttachment(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageStatus.READ -> {
|
MessageStatus.READ -> {
|
||||||
Icon(
|
Box(modifier = Modifier.height(14.dp)) {
|
||||||
painter = TelegramIcons.Done,
|
Icon(
|
||||||
contentDescription = null,
|
painter = TelegramIcons.Done,
|
||||||
tint = Color.White,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(14.dp)
|
tint = Color.White,
|
||||||
)
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
@@ -1693,13 +1701,22 @@ fun FileAttachment(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageStatus.READ -> {
|
MessageStatus.READ -> {
|
||||||
Icon(
|
Box(modifier = Modifier.height(14.dp)) {
|
||||||
painter = TelegramIcons.Done,
|
Icon(
|
||||||
contentDescription = null,
|
painter = TelegramIcons.Done,
|
||||||
tint =
|
contentDescription = null,
|
||||||
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
|
tint =
|
||||||
modifier = Modifier.size(14.dp)
|
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
|
||||||
)
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint =
|
||||||
|
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
|
||||||
|
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageStatus.ERROR -> {
|
MessageStatus.ERROR -> {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -2089,12 +2106,20 @@ fun AvatarAttachment(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
MessageStatus.READ -> {
|
MessageStatus.READ -> {
|
||||||
Icon(
|
Box(modifier = Modifier.height(14.dp)) {
|
||||||
painter = TelegramIcons.Done,
|
Icon(
|
||||||
contentDescription = "Read",
|
painter = TelegramIcons.Done,
|
||||||
tint = Color.White,
|
contentDescription = "Read",
|
||||||
modifier = Modifier.size(14.dp)
|
tint = Color.White,
|
||||||
)
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = "Read",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
MessageStatus.ERROR -> {
|
MessageStatus.ERROR -> {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.geometry.CornerRadius
|
|||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.Layout
|
import androidx.compose.ui.layout.Layout
|
||||||
@@ -41,6 +42,7 @@ import androidx.compose.ui.layout.boundsInWindow
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -292,7 +294,9 @@ fun MessageBubble(
|
|||||||
contextMenuContent: @Composable () -> Unit = {}
|
contextMenuContent: @Composable () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// Swipe-to-reply state
|
// Swipe-to-reply state
|
||||||
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
var swipeOffset by remember { mutableStateOf(0f) }
|
var swipeOffset by remember { mutableStateOf(0f) }
|
||||||
|
var hapticTriggered by remember { mutableStateOf(false) }
|
||||||
val swipeThreshold = 80f
|
val swipeThreshold = 80f
|
||||||
val maxSwipe = 120f
|
val maxSwipe = 120f
|
||||||
|
|
||||||
@@ -402,8 +406,12 @@ fun MessageBubble(
|
|||||||
onSwipeToReply()
|
onSwipeToReply()
|
||||||
}
|
}
|
||||||
swipeOffset = 0f
|
swipeOffset = 0f
|
||||||
|
hapticTriggered = false
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
swipeOffset = 0f
|
||||||
|
hapticTriggered = false
|
||||||
},
|
},
|
||||||
onDragCancel = { swipeOffset = 0f },
|
|
||||||
onHorizontalDrag = { change, dragAmount ->
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
// Только свайп влево (отрицательное значение)
|
// Только свайп влево (отрицательное значение)
|
||||||
if (dragAmount < 0 || swipeOffset < 0) {
|
if (dragAmount < 0 || swipeOffset < 0) {
|
||||||
@@ -411,6 +419,15 @@ fun MessageBubble(
|
|||||||
val newOffset = swipeOffset + dragAmount
|
val newOffset = swipeOffset + dragAmount
|
||||||
swipeOffset =
|
swipeOffset =
|
||||||
newOffset.coerceIn(-maxSwipe, 0f)
|
newOffset.coerceIn(-maxSwipe, 0f)
|
||||||
|
// Haptic при достижении порога
|
||||||
|
if (swipeOffset <= -swipeThreshold && !hapticTriggered) {
|
||||||
|
hapticTriggered = true
|
||||||
|
hapticFeedback.performHapticFeedback(
|
||||||
|
HapticFeedbackType.LongPress
|
||||||
|
)
|
||||||
|
} else if (swipeOffset > -swipeThreshold && hapticTriggered) {
|
||||||
|
hapticTriggered = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package com.rosetta.messenger.ui.chats.components
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.ScaleGestureDetector
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.camera.core.Camera
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
import androidx.camera.core.ImageCapture
|
import androidx.camera.core.ImageCapture
|
||||||
import androidx.camera.core.ImageCaptureException
|
import androidx.camera.core.ImageCaptureException
|
||||||
@@ -28,6 +30,8 @@ import androidx.compose.material.icons.filled.FlipCameraAndroid
|
|||||||
import androidx.compose.material.icons.filled.FlashOff
|
import androidx.compose.material.icons.filled.FlashOff
|
||||||
import androidx.compose.material.icons.filled.FlashOn
|
import androidx.compose.material.icons.filled.FlashOn
|
||||||
import androidx.compose.material.icons.filled.FlashAuto
|
import androidx.compose.material.icons.filled.FlashAuto
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -39,7 +43,10 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -90,6 +97,10 @@ fun InAppCameraScreen(
|
|||||||
// Camera references
|
// Camera references
|
||||||
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
|
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
|
||||||
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
|
||||||
|
var camera by remember { mutableStateOf<Camera?>(null) }
|
||||||
|
|
||||||
|
// Zoom state — linear 0..1 progress synced with camera
|
||||||
|
var zoomLinearProgress by remember { mutableFloatStateOf(0.5f) }
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// 🎬 FADE ANIMATION (как в ImageEditorScreen)
|
// 🎬 FADE ANIMATION (как в ImageEditorScreen)
|
||||||
@@ -157,7 +168,7 @@ fun InAppCameraScreen(
|
|||||||
|
|
||||||
val progress = animationProgress.value
|
val progress = animationProgress.value
|
||||||
val currentStatusColor = androidx.core.graphics.ColorUtils.blendARGB(
|
val currentStatusColor = androidx.core.graphics.ColorUtils.blendARGB(
|
||||||
originalStatusBarColor, android.graphics.Color.BLACK, progress
|
originalStatusBarColor, android.graphics.Color.TRANSPARENT, progress
|
||||||
)
|
)
|
||||||
val currentNavColor = androidx.core.graphics.ColorUtils.blendARGB(
|
val currentNavColor = androidx.core.graphics.ColorUtils.blendARGB(
|
||||||
originalNavigationBarColor, android.graphics.Color.BLACK, progress
|
originalNavigationBarColor, android.graphics.Color.BLACK, progress
|
||||||
@@ -262,12 +273,13 @@ fun InAppCameraScreen(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
provider.unbindAll()
|
provider.unbindAll()
|
||||||
provider.bindToLifecycle(
|
camera = provider.bindToLifecycle(
|
||||||
lifecycleOwner,
|
lifecycleOwner,
|
||||||
cameraSelector,
|
cameraSelector,
|
||||||
preview,
|
preview,
|
||||||
capture
|
capture
|
||||||
)
|
)
|
||||||
|
camera?.cameraControl?.setLinearZoom(zoomLinearProgress)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,7 +302,7 @@ fun InAppCameraScreen(
|
|||||||
.graphicsLayer { alpha = animationProgress.value }
|
.graphicsLayer { alpha = animationProgress.value }
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
) {
|
) {
|
||||||
// Camera Preview
|
// Camera Preview with pinch-to-zoom
|
||||||
AndroidView(
|
AndroidView(
|
||||||
factory = { ctx ->
|
factory = { ctx ->
|
||||||
PreviewView(ctx).apply {
|
PreviewView(ctx).apply {
|
||||||
@@ -301,6 +313,31 @@ fun InAppCameraScreen(
|
|||||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||||
previewView = this
|
previewView = this
|
||||||
|
|
||||||
|
// Pinch-to-zoom via ScaleGestureDetector
|
||||||
|
val scaleGestureDetector = ScaleGestureDetector(ctx,
|
||||||
|
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||||
|
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||||
|
val cam = camera ?: return true
|
||||||
|
val zoomState = cam.cameraInfo.zoomState.value ?: return true
|
||||||
|
val currentZoom = zoomState.zoomRatio
|
||||||
|
val newZoom = currentZoom * detector.scaleFactor
|
||||||
|
cam.cameraControl.setZoomRatio(newZoom)
|
||||||
|
// Sync slider with pinch: convert ratio to linear 0..1
|
||||||
|
val minZoom = zoomState.minZoomRatio
|
||||||
|
val maxZoom = zoomState.maxZoomRatio
|
||||||
|
val clampedZoom = newZoom.coerceIn(minZoom, maxZoom)
|
||||||
|
zoomLinearProgress = if (maxZoom > minZoom)
|
||||||
|
((clampedZoom - minZoom) / (maxZoom - minZoom)).coerceIn(0f, 1f)
|
||||||
|
else 0f
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
setOnTouchListener { _, event ->
|
||||||
|
scaleGestureDetector.onTouchEvent(event)
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
@@ -357,6 +394,111 @@ fun InAppCameraScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Zoom slider with −/+ buttons (Telegram-style) ──
|
||||||
|
val zoomState = camera?.cameraInfo?.zoomState?.value
|
||||||
|
val currentZoomRatio = zoomState?.zoomRatio ?: 1f
|
||||||
|
val minZoomRatio = zoomState?.minZoomRatio ?: 1f
|
||||||
|
val maxZoomRatio = zoomState?.maxZoomRatio ?: 1f
|
||||||
|
val zoomLabel = "%.1fx".format(currentZoomRatio)
|
||||||
|
|
||||||
|
// Only show slider if camera supports zoom range > 1
|
||||||
|
if (maxZoomRatio > minZoomRatio + 0.1f) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 130.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Zoom ratio label (e.g. "2.3x")
|
||||||
|
Text(
|
||||||
|
text = zoomLabel,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f), RoundedCornerShape(10.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
|
||||||
|
// Slider row: [−] ━━━━━━━━━ [+]
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Minus button
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f), CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
val newProgress = (zoomLinearProgress - 0.1f).coerceIn(0f, 1f)
|
||||||
|
zoomLinearProgress = newProgress
|
||||||
|
camera?.cameraControl?.setLinearZoom(newProgress)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Remove,
|
||||||
|
contentDescription = "Zoom out",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slider track
|
||||||
|
Slider(
|
||||||
|
value = zoomLinearProgress,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
zoomLinearProgress = newValue
|
||||||
|
camera?.cameraControl?.setLinearZoom(newValue)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = Color.White,
|
||||||
|
activeTrackColor = Color.White,
|
||||||
|
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plus button
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.background(Color.Black.copy(alpha = 0.4f), CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
val newProgress = (zoomLinearProgress + 0.1f).coerceIn(0f, 1f)
|
||||||
|
zoomLinearProgress = newProgress
|
||||||
|
camera?.cameraControl?.setLinearZoom(newProgress)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Add,
|
||||||
|
contentDescription = "Zoom in",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bottom controls (Shutter + Flip)
|
// Bottom controls (Shutter + Flip)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
) : EditText(context, attrs, defStyleAttr) {
|
) : EditText(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
var onTextChange: ((String) -> Unit)? = null
|
var onTextChange: ((String) -> Unit)? = null
|
||||||
|
/** Called when the view's measured height changes (for multiline expansion animation). */
|
||||||
|
var onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
||||||
private var isUpdating = false
|
private var isUpdating = false
|
||||||
|
private var lastMeasuredHeight: Int = 0
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
// Regex для эмодзи - public для доступа из других компонентов
|
// Regex для эмодзи - public для доступа из других компонентов
|
||||||
@@ -123,6 +126,14 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
if (oldh != 0 && h != oldh) {
|
||||||
|
onHeightChanged?.invoke(oldh, h)
|
||||||
|
}
|
||||||
|
lastMeasuredHeight = h
|
||||||
|
}
|
||||||
|
|
||||||
fun setTextWithEmojis(newText: String) {
|
fun setTextWithEmojis(newText: String) {
|
||||||
if (newText == text.toString()) return
|
if (newText == text.toString()) return
|
||||||
isUpdating = true
|
isUpdating = true
|
||||||
@@ -269,7 +280,8 @@ fun AppleEmojiTextField(
|
|||||||
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
|
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
|
||||||
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
||||||
requestFocus: Boolean = false,
|
requestFocus: Boolean = false,
|
||||||
onFocusChanged: ((Boolean) -> Unit)? = null
|
onFocusChanged: ((Boolean) -> Unit)? = null,
|
||||||
|
onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
// Храним ссылку на view для управления фокусом
|
// Храним ссылку на view для управления фокусом
|
||||||
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
||||||
@@ -292,6 +304,7 @@ fun AppleEmojiTextField(
|
|||||||
setHint(hint)
|
setHint(hint)
|
||||||
setTextSize(textSize)
|
setTextSize(textSize)
|
||||||
onTextChange = onValueChange
|
onTextChange = onValueChange
|
||||||
|
this.onHeightChanged = onHeightChanged
|
||||||
// Убираем все возможные фоны у EditText
|
// Убираем все возможные фоны у EditText
|
||||||
background = null
|
background = null
|
||||||
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
setBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ private fun EmojiPickerContent(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(keyboardHeight)
|
.heightIn(max = keyboardHeight)
|
||||||
.background(panelBackground)
|
.background(panelBackground)
|
||||||
) {
|
) {
|
||||||
// ============ КАТЕГОРИИ (синхронизированы с pager) ============
|
// ============ КАТЕГОРИИ (синхронизированы с pager) ============
|
||||||
|
|||||||
Reference in New Issue
Block a user