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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
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"
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -130,7 +130,7 @@ private fun EmojiPickerContent(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(keyboardHeight)
|
||||
.heightIn(max = keyboardHeight)
|
||||
.background(panelBackground)
|
||||
) {
|
||||
// ============ КАТЕГОРИИ (синхронизированы с pager) ============
|
||||
|
||||
Reference in New Issue
Block a user