diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 83d8043..c497102 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 49b0eb6..b72ff96 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -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 = diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt index 882f57a..22bfd52 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -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>(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 { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt index bfea362..67a61e4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/AttachAlertComponents.kt @@ -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() - } - ) - } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 0af1893..f40b504 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index f2c359c..e3b1a92 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index f2c5f2e..658aa40 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 + } } } ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index f2f285f..dfe93b6 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -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(null) } var cameraProvider by remember { mutableStateOf(null) } + var camera by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 89283ca..449a4a1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -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(null) } @@ -292,6 +304,7 @@ fun AppleEmojiTextField( setHint(hint) setTextSize(textSize) onTextChange = onValueChange + this.onHeightChanged = onHeightChanged // Убираем все возможные фоны у EditText background = null setBackgroundColor(android.graphics.Color.TRANSPARENT) diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt index 429b1be..7c8f2e4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/OptimizedEmojiPicker.kt @@ -130,7 +130,7 @@ private fun EmojiPickerContent( Column( modifier = modifier .fillMaxWidth() - .height(keyboardHeight) + .heightIn(max = keyboardHeight) .background(panelBackground) ) { // ============ КАТЕГОРИИ (синхронизированы с pager) ============