feat: Implement pinch-to-zoom functionality in InAppCameraScreen and add zoom controls

This commit is contained in:
2026-02-23 00:49:29 +05:00
parent 290143dfc4
commit f4cb3f8253
10 changed files with 503 additions and 260 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.0.4"
val rosettaVersionCode = 4 // Increment on each release
val rosettaVersionName = "1.0.5"
val rosettaVersionCode = 5 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -17,13 +17,15 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- New attachment panel with tabs (photos, files, avatar)
- Message drafts — text is saved when leaving a chat
- Circular reveal animation for theme switching
- Photo albums in media picker
- Camera flash mode is now saved between sessions
- Swipe-back gesture fixes
- UI and performance improvements
- Emoji keyboard in attachment panel with smooth transitions
- File browser — send any file from device storage
- Send gallery photos as files (without compression)
- Camera: pinch-to-zoom, zoom slider, transparent status bar
- Double checkmarks for read photos, files and avatars
- Haptic feedback on swipe-to-reply
- System accounts hidden from forward picker
- Smoother caption bar animations
- Bug fixes and performance improvements
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -25,12 +25,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import androidx.compose.ui.res.painterResource
import java.util.*
import kotlinx.coroutines.launch
@@ -65,6 +68,11 @@ fun ForwardChatPickerBottomSheet(
val forwardMessages by ForwardManager.forwardMessages.collectAsState()
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()) }
@@ -187,7 +195,7 @@ fun ForwardChatPickerBottomSheet(
Divider(color = dividerColor, thickness = 0.5.dp)
// Список диалогов
if (dialogs.isEmpty()) {
if (filteredDialogs.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxWidth().height(200.dp),
@@ -214,7 +222,7 @@ fun ForwardChatPickerBottomSheet(
Modifier.fillMaxWidth()
.weight(1f)
) {
items(dialogs, key = { it.opponentKey }) { dialog ->
items(filteredDialogs, key = { it.opponentKey }) { dialog ->
val isSelected = dialog.opponentKey in selectedChats
ForwardDialogItem(
dialog = dialog,
@@ -233,7 +241,7 @@ fun ForwardChatPickerBottomSheet(
)
// Сепаратор между диалогами
if (dialog != dialogs.last()) {
if (dialog != filteredDialogs.last()) {
Divider(
modifier = Modifier.padding(start = 76.dp),
color = dividerColor,
@@ -252,7 +260,7 @@ fun ForwardChatPickerBottomSheet(
val hasSelection = selectedChats.isNotEmpty()
Button(
onClick = {
val selectedDialogs = dialogs.filter { it.opponentKey in selectedChats }
val selectedDialogs = filteredDialogs.filter { it.opponentKey in selectedChats }
onChatsSelected(selectedDialogs)
},
enabled = hasSelection,
@@ -339,14 +347,14 @@ private fun ForwardDialogItem(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(PrimaryBlue.copy(alpha = 0.15f)),
.background(PrimaryBlue),
contentAlignment = Alignment.Center
) {
Text(
text = "📁",
color = PrimaryBlue,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp
Icon(
painter = painterResource(R.drawable.bookmark_outlined),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
} else {

View File

@@ -784,91 +784,103 @@ internal fun CaptionBar(
captionText: String,
onCaptionChange: (String) -> Unit,
isCaptionKeyboardVisible: Boolean,
isEmojiVisible: Boolean = false,
onToggleKeyboard: () -> Unit,
onFocusCaption: () -> Unit,
onViewCreated: (com.rosetta.messenger.ui.components.AppleEmojiEditTextView) -> Unit,
isDarkTheme: Boolean,
density: androidx.compose.ui.unit.Density
) {
val captionBarHeight = 52.dp * captionBarProgress
Box(
modifier = Modifier
.fillMaxWidth()
.height(captionBarHeight)
.graphicsLayer { clip = true }
// Telegram-style CaptionBar:
// - AnimatedVisibility for show/hide (expandVertically + fadeIn/Out, 180ms)
// - animateContentSize for smooth multiline expansion (200ms CubicBezier,
// matching Telegram's captionEditTextTopOffset animation)
val captionMultilineEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)
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) {
val captionOffsetPx = with(density) { (1f - captionBarProgress) * 18.dp.toPx() }
Column(
Column(
modifier = Modifier
.fillMaxWidth()
// animateContentSize: smooth multiline expansion/collapse
// like Telegram's captionEditTextTopOffset (200ms CubicBezier)
.animateContentSize(
animationSpec = tween(200, easing = captionMultilineEasing)
)
) {
// Thin divider
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = captionBarProgress
translationY = captionOffsetPx
}
.height(0.5.dp)
.background(
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(
modifier = Modifier
.fillMaxWidth()
.height(0.5.dp)
.background(
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)
.weight(1f)
.heightIn(min = 28.dp, max = 120.dp),
contentAlignment = Alignment.CenterStart
) {
val captionIconPainter =
if (isCaptionKeyboardVisible) 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)
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.
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()
}
)
}
}
}
}

View File

@@ -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.PhotoPreviewWithCaptionScreen
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 kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -103,10 +108,11 @@ private fun updatePopupImeFocusable(rootView: View, imeFocusable: Boolean) {
} else {
params.flags = params.flags or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM
}
// Don't change softInputMode on the Popup window.
// Compose Popup + enableEdgeToEdge() handles keyboard insets via WindowInsets
// (no physical window resize). Manually changing to ADJUST_RESIZE causes
// double-subtraction: system shrinks window AND we subtract imeInsets.
// Telegram approach: SOFT_INPUT_ADJUST_NOTHING on the Popup window itself.
// This prevents the system from resizing or panning the Popup when keyboard
// opens. The sheet stays at the same height — we handle keyboard space
// internally via a bottom spacer (like Telegram's AdjustPanLayoutHelper).
params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
try {
wm.updateViewLayout(rootView, params)
AttachAlertDebugLog.log("AttachAlert", "updatePopupImeFocusable($imeFocusable) — flags updated OK")
@@ -166,6 +172,10 @@ fun ChatAttachAlert(
var pendingDismissAfterConfirm by remember { mutableStateOf<(() -> Unit)?>(null) }
var hadSelection 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 imeBottomInsetPx by remember { mutableIntStateOf(0) }
@@ -189,6 +199,9 @@ fun ChatAttachAlert(
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
pendingCaptionFocus = false
captionInputActive = false
showEmojiPicker = false
coordinator.isEmojiVisible = false
coordinator.isEmojiBoxVisible = false
focusManager.clearFocus(force = true)
captionEditTextView?.clearFocus()
@@ -218,6 +231,61 @@ fun ChatAttachAlert(
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
// ═══════════════════════════════════════════════════════════
@@ -233,7 +301,9 @@ fun ChatAttachAlert(
(screenHeightPx + statusBarInsetPx).coerceAtLeast(screenHeightPx * 0.95f)
val minHeightPx = screenHeightPx * 0.2f
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 selectionHeaderExtraPx = with(density) { 26.dp.toPx() }
@@ -607,14 +677,12 @@ fun ChatAttachAlert(
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
insetsController?.isAppearanceLightStatusBars = !dark
} else {
window.statusBarColor = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
)
// Apply scrim to status bar so it matches the overlay darkness
val scrimInt = (alpha * 255).toInt().coerceIn(0, 255)
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false
}
window.navigationBarColor = android.graphics.Color.argb(
(alpha * 255).toInt().coerceIn(0, 255), 0, 0, 0
)
window.navigationBarColor = android.graphics.Color.TRANSPARENT
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 =
(imeBottomInsetPx.toFloat() - navigationBarInsetPx).coerceAtLeast(0f)
// Only shrink the sheet for keyboard when caption input is active.
// When FLAG_ALT_FOCUSABLE_IM is set, keyboard belongs to chat input behind picker.
val targetKeyboardInsetPx =
val keyboardSpacerPxRaw =
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
if (imeBottomInsetPx > 0 || captionInputActive) {
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")
// Track whether emoji box was previously visible to detect the moment it disappears
val prevEmojiBoxVisible = remember { mutableStateOf(false) }
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(
modifier = Modifier
@@ -718,8 +826,9 @@ fun ChatAttachAlert(
.padding(bottom = navBarDp),
contentAlignment = Alignment.BottomCenter
) {
val visibleSheetHeightPx =
(sheetHeightPx.value - animatedKeyboardInsetPx).coerceAtLeast(minHeightPx)
// Sheet height stays constant — keyboard space is handled by
// internal Spacer, not by shrinking the container (Telegram approach).
val visibleSheetHeightPx = sheetHeightPx.value.coerceAtLeast(minHeightPx)
val currentHeightDp = with(density) { visibleSheetHeightPx.toDp() }
val slideOffset = (visibleSheetHeightPx * animatedOffset).toInt()
val expandProgress =
@@ -936,15 +1045,39 @@ fun ChatAttachAlert(
captionText = state.captionText,
onCaptionChange = { viewModel.updateCaption(it) },
isCaptionKeyboardVisible = isCaptionKeyboardVisible,
onToggleKeyboard = {
if (isCaptionKeyboardVisible) hideKeyboard()
else focusCaptionInput()
},
isEmojiVisible = showEmojiPicker,
onToggleKeyboard = { toggleEmojiPicker() },
onFocusCaption = { focusCaptionInput() },
onViewCreated = { captionEditTextView = it },
isDarkTheme = isDarkTheme,
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
// ── Floating Send Button ──
@@ -952,7 +1085,7 @@ fun ChatAttachAlert(
val sendButtonVisible = state.selectedItemOrder.isNotEmpty()
val sendButtonProgress by animateFloatAsState(
targetValue = if (sendButtonVisible) 1f else 0f,
animationSpec = tween(180, easing = FastOutSlowInEasing),
animationSpec = tween(180, easing = captionLiftEasing),
label = "send_button_progress"
)
FloatingSendButton(
@@ -966,120 +1099,11 @@ fun ChatAttachAlert(
onMediaSelected(selected, caption)
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
}
}

View File

@@ -1267,12 +1267,20 @@ fun ImageAttachment(
)
}
MessageStatus.READ -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Box(modifier = Modifier.height(14.dp)) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
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 -> {}
}
@@ -1693,13 +1701,22 @@ fun FileAttachment(
)
}
MessageStatus.READ -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint =
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
modifier = Modifier.size(14.dp)
)
Box(modifier = Modifier.height(14.dp)) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint =
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 -> {
Icon(
@@ -2089,12 +2106,20 @@ fun AvatarAttachment(
)
}
MessageStatus.READ -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Read",
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Box(modifier = Modifier.height(14.dp)) {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Read",
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 -> {
Icon(

View File

@@ -34,6 +34,7 @@ import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
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.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
@@ -292,7 +294,9 @@ fun MessageBubble(
contextMenuContent: @Composable () -> Unit = {}
) {
// Swipe-to-reply state
val hapticFeedback = LocalHapticFeedback.current
var swipeOffset by remember { mutableStateOf(0f) }
var hapticTriggered by remember { mutableStateOf(false) }
val swipeThreshold = 80f
val maxSwipe = 120f
@@ -402,8 +406,12 @@ fun MessageBubble(
onSwipeToReply()
}
swipeOffset = 0f
hapticTriggered = false
},
onDragCancel = {
swipeOffset = 0f
hapticTriggered = false
},
onDragCancel = { swipeOffset = 0f },
onHorizontalDrag = { change, dragAmount ->
// Только свайп влево (отрицательное значение)
if (dragAmount < 0 || swipeOffset < 0) {
@@ -411,6 +419,15 @@ fun MessageBubble(
val newOffset = swipeOffset + dragAmount
swipeOffset =
newOffset.coerceIn(-maxSwipe, 0f)
// Haptic при достижении порога
if (swipeOffset <= -swipeThreshold && !hapticTriggered) {
hapticTriggered = true
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
} else if (swipeOffset > -swipeThreshold && hapticTriggered) {
hapticTriggered = false
}
}
}
)

View File

@@ -3,10 +3,12 @@ package com.rosetta.messenger.ui.chats.components
import android.content.Context
import android.net.Uri
import android.util.Log
import android.view.ScaleGestureDetector
import android.view.ViewGroup
import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
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.FlashOn
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.runtime.*
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.LocalLifecycleOwner
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.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import kotlinx.coroutines.delay
@@ -90,6 +97,10 @@ fun InAppCameraScreen(
// Camera references
var imageCapture by remember { mutableStateOf<ImageCapture?>(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)
@@ -157,7 +168,7 @@ fun InAppCameraScreen(
val progress = animationProgress.value
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(
originalNavigationBarColor, android.graphics.Color.BLACK, progress
@@ -262,12 +273,13 @@ fun InAppCameraScreen(
try {
provider.unbindAll()
provider.bindToLifecycle(
camera = provider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
capture
)
camera?.cameraControl?.setLinearZoom(zoomLinearProgress)
} catch (e: Exception) {
}
}
@@ -290,7 +302,7 @@ fun InAppCameraScreen(
.graphicsLayer { alpha = animationProgress.value }
.background(Color.Black)
) {
// Camera Preview
// Camera Preview with pinch-to-zoom
AndroidView(
factory = { ctx ->
PreviewView(ctx).apply {
@@ -301,6 +313,31 @@ fun InAppCameraScreen(
scaleType = PreviewView.ScaleType.FILL_CENTER
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
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()
@@ -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)
Row(
modifier = Modifier

View File

@@ -43,7 +43,10 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
) : EditText(context, attrs, defStyleAttr) {
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 lastMeasuredHeight: Int = 0
companion object {
// 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) {
if (newText == text.toString()) return
isUpdating = true
@@ -269,7 +280,8 @@ fun AppleEmojiTextField(
hintColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Gray,
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
requestFocus: Boolean = false,
onFocusChanged: ((Boolean) -> Unit)? = null
onFocusChanged: ((Boolean) -> Unit)? = null,
onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
) {
// Храним ссылку на view для управления фокусом
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
@@ -292,6 +304,7 @@ fun AppleEmojiTextField(
setHint(hint)
setTextSize(textSize)
onTextChange = onValueChange
this.onHeightChanged = onHeightChanged
// Убираем все возможные фоны у EditText
background = null
setBackgroundColor(android.graphics.Color.TRANSPARENT)

View File

@@ -130,7 +130,7 @@ private fun EmojiPickerContent(
Column(
modifier = modifier
.fillMaxWidth()
.height(keyboardHeight)
.heightIn(max = keyboardHeight)
.background(panelBackground)
) {
// ============ КАТЕГОРИИ (синхронизированы с pager) ============