feat: implement system bars style utility for consistent UI behavior

This commit is contained in:
k1ngsterr1
2026-02-09 14:26:59 +05:00
parent b6e4f20c4c
commit 079995958f
5 changed files with 264 additions and 250 deletions

View File

@@ -52,7 +52,6 @@ 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 androidx.core.view.WindowCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -79,6 +78,7 @@ import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat
import java.util.Locale
@@ -191,22 +191,17 @@ fun ChatDetailScreen(
var imageViewerSourceBounds by remember { mutableStateOf<ImageSourceBounds?>(null) }
// 🎨 Управление статус баром
DisposableEffect(isDarkTheme, showImageViewer) {
val insetsController = window?.let { WindowCompat.getInsetsController(it, view) }
DisposableEffect(isDarkTheme, showImageViewer, window, view) {
if (showImageViewer) {
// 📸 При просмотре фото - чёрный статус бар
window?.statusBarColor = android.graphics.Color.BLACK
insetsController?.isAppearanceLightStatusBars = false
// 📸 При просмотре фото - чёрные system bars
SystemBarsStyleUtils.applyFullscreenDark(window, view)
} else {
// Обычный режим - прозрачный статус бар, иконки по теме
window?.statusBarColor = android.graphics.Color.TRANSPARENT
insetsController?.isAppearanceLightStatusBars = !isDarkTheme
// Обычный режим чата
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
}
onDispose {
// Восстанавливаем прозрачный статус бар при выходе
window?.statusBarColor = android.graphics.Color.TRANSPARENT
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
}
}
@@ -1990,11 +1985,7 @@ fun ChatDetailScreen(
},
onClosingStart = {
// Сразу сбрасываем status bar при начале закрытия (до анимации)
window?.statusBarColor = android.graphics.Color.TRANSPARENT
window?.let { w ->
WindowCompat.getInsetsController(w, view)
?.isAppearanceLightStatusBars = !isDarkTheme
}
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds

View File

@@ -60,6 +60,7 @@ import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.yalantis.ucrop.UCrop
import compose.icons.TablerIcons
import compose.icons.tablericons.*
@@ -68,7 +69,6 @@ import ja.burhanrashid52.photoeditor.PhotoEditorView
import ja.burhanrashid52.photoeditor.SaveSettings
import java.io.File
import kotlin.coroutines.resume
import androidx.core.view.WindowCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -170,59 +170,26 @@ fun ImageEditorScreen(
}
}
// 🎨 Плавная анимация status bar синхронно с fade
// 🎨 System bars style
val activity = context as? Activity
val window = activity?.window
// Сохраняем оригинальные цвета один раз
val originalStatusBarColor = remember { window?.statusBarColor ?: android.graphics.Color.WHITE }
val originalNavigationBarColor = remember { window?.navigationBarColor ?: android.graphics.Color.WHITE }
val insetsController = remember(window, view) { window?.let { WindowCompat.getInsetsController(it, view) } }
val originalLightStatusBars = remember { insetsController?.isAppearanceLightStatusBars ?: true }
val originalLightNavigationBars = remember { insetsController?.isAppearanceLightNavigationBars ?: true }
// ⚡ Анимация цвета status bar синхронно с fade
LaunchedEffect(animationProgress.value) {
if (window == null || insetsController == null) return@LaunchedEffect
val progress = animationProgress.value
// Интерполируем цвет: оригинальный (progress=0) -> черный (progress=1)
val currentStatusColor = androidx.core.graphics.ColorUtils.blendARGB(
originalStatusBarColor,
android.graphics.Color.BLACK,
progress
)
val currentNavColor = androidx.core.graphics.ColorUtils.blendARGB(
originalNavigationBarColor,
android.graphics.Color.BLACK,
progress
)
window.statusBarColor = currentStatusColor
window.navigationBarColor = currentNavColor
// Иконки: светлые (false) когда progress > 0.5, иначе оригинальные
insetsController.isAppearanceLightStatusBars = progress < 0.5f && originalLightStatusBars
insetsController.isAppearanceLightNavigationBars = progress < 0.5f && originalLightNavigationBars
val systemBarsState = remember(window, view) {
SystemBarsStyleUtils.capture(window, view)
}
// Восстановление при dispose
DisposableEffect(window) {
onDispose {
if (window == null || insetsController == null) return@onDispose
window.statusBarColor = originalStatusBarColor
insetsController.isAppearanceLightStatusBars = originalLightStatusBars
// Navigation bar: восстанавливаем только если есть нативные кнопки
if (com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)) {
window.navigationBarColor = originalNavigationBarColor
insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
} else {
insetsController.hide(androidx.core.view.WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
LaunchedEffect(window, view) {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
}
DisposableEffect(window, view, isDarkTheme, systemBarsState) {
onDispose {
SystemBarsStyleUtils.restoreChatAfterFullscreen(
window = window,
view = view,
context = context,
isDarkTheme = isDarkTheme,
state = systemBarsState
)
}
}
@@ -408,43 +375,47 @@ fun ImageEditorScreen(
down.consume() // Поглощаем все touch события
}
}
.graphicsLayer { alpha = animationProgress.value } // ⚡ Всё плавно fade
.background(Color.Black)
) {
// ═══════════════════════════════════════════════════════════
// 📸 FULLSCREEN PHOTO - занимает ВЕСЬ экран, не реагирует на клавиатуру
// ═══════════════════════════════════════════════════════════
AndroidView(
factory = { ctx ->
PhotoEditorView(ctx).apply {
photoEditorView = this
Box(
modifier =
Modifier.fillMaxSize()
.graphicsLayer { alpha = animationProgress.value }
) {
// ═══════════════════════════════════════════════════════════
// 📸 FULLSCREEN PHOTO - занимает ВЕСЬ экран, не реагирует на клавиатуру
// ═══════════════════════════════════════════════════════════
AndroidView(
factory = { ctx ->
PhotoEditorView(ctx).apply {
photoEditorView = this
// Убираем отступы
setPadding(0, 0, 0, 0)
setBackgroundColor(android.graphics.Color.BLACK)
// Простой FIT_CENTER - показывает ВСЁ фото, центрирует
source.apply {
setImageURI(currentImageUri)
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
// Убираем отступы
setPadding(0, 0, 0, 0)
}
setBackgroundColor(android.graphics.Color.BLACK)
photoEditor = PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
}
},
update = { view ->
view.source.rotation = rotationAngle
view.source.scaleX = if (isFlippedHorizontally) -1f else 1f
view.source.scaleY = if (isFlippedVertically) -1f else 1f
},
// КРИТИЧНО: fillMaxSize без imePadding - фото НЕ сжимается при клавиатуре
modifier = Modifier.fillMaxSize()
)
// Простой FIT_CENTER - показывает ВСЁ фото, центрирует
source.apply {
setImageURI(currentImageUri)
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
setPadding(0, 0, 0, 0)
}
photoEditor = PhotoEditor.Builder(ctx, this)
.setPinchTextScalable(true)
.setClipSourceImage(true)
.build()
}
},
update = { view ->
view.source.rotation = rotationAngle
view.source.scaleX = if (isFlippedHorizontally) -1f else 1f
view.source.scaleY = if (isFlippedVertically) -1f else 1f
},
// КРИТИЧНО: fillMaxSize без imePadding - фото НЕ сжимается при клавиатуре
modifier = Modifier.fillMaxSize()
)
// ═══════════════════════════════════════════════════════════
// 🎛️ TOP BAR - Solid black (Telegram style)
@@ -682,14 +653,14 @@ fun ImageEditorScreen(
// ═══════════════════════════════════════════════════════════
val isKeyboardOpen = WindowInsets.ime.getBottom(LocalDensity.current) > 0
AnimatedVisibility(
visible = !isKeyboardOpen && !showEmojiPicker && !coordinator.isEmojiBoxVisible,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
) {
AnimatedVisibility(
visible = !isKeyboardOpen && !showEmojiPicker && !coordinator.isEmojiBoxVisible,
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
) {
Column(
modifier = Modifier
.fillMaxWidth()
@@ -779,6 +750,7 @@ fun ImageEditorScreen(
}
}
}
}
}
/**
@@ -1539,59 +1511,26 @@ fun MultiImageEditorScreen(
}
}
// 🎨 Плавная анимация status bar синхронно с fade
// 🎨 System bars style
val activity = context as? Activity
val window = activity?.window
// Сохраняем оригинальные цвета один раз
val originalStatusBarColor = remember { window?.statusBarColor ?: android.graphics.Color.WHITE }
val originalNavigationBarColor = remember { window?.navigationBarColor ?: android.graphics.Color.WHITE }
val insetsController = remember(window, view) { window?.let { WindowCompat.getInsetsController(it, view) } }
val originalLightStatusBars = remember { insetsController?.isAppearanceLightStatusBars ?: true }
val originalLightNavigationBars = remember { insetsController?.isAppearanceLightNavigationBars ?: true }
// ⚡ Анимация цвета status bar синхронно с fade
LaunchedEffect(animationProgress.value) {
if (window == null || insetsController == null) return@LaunchedEffect
val progress = animationProgress.value
// Интерполируем цвет: оригинальный (progress=0) -> черный (progress=1)
val currentStatusColor = androidx.core.graphics.ColorUtils.blendARGB(
originalStatusBarColor,
android.graphics.Color.BLACK,
progress
)
val currentNavColor = androidx.core.graphics.ColorUtils.blendARGB(
originalNavigationBarColor,
android.graphics.Color.BLACK,
progress
)
window.statusBarColor = currentStatusColor
window.navigationBarColor = currentNavColor
// Иконки: светлые (false) когда progress > 0.5, иначе оригинальные
insetsController.isAppearanceLightStatusBars = progress < 0.5f && originalLightStatusBars
insetsController.isAppearanceLightNavigationBars = progress < 0.5f && originalLightNavigationBars
val systemBarsState = remember(window, view) {
SystemBarsStyleUtils.capture(window, view)
}
// Восстановление при dispose
DisposableEffect(window) {
onDispose {
if (window == null || insetsController == null) return@onDispose
window.statusBarColor = originalStatusBarColor
insetsController.isAppearanceLightStatusBars = originalLightStatusBars
// Navigation bar: восстанавливаем только если есть нативные кнопки
if (com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)) {
window.navigationBarColor = originalNavigationBarColor
insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
} else {
insetsController.hide(androidx.core.view.WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
LaunchedEffect(window, view) {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
}
DisposableEffect(window, view, isDarkTheme, systemBarsState) {
onDispose {
SystemBarsStyleUtils.restoreChatAfterFullscreen(
window = window,
view = view,
context = context,
isDarkTheme = isDarkTheme,
state = systemBarsState
)
}
}
@@ -1648,76 +1587,82 @@ fun MultiImageEditorScreen(
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = animationProgress.value } // ⚡ Всё плавно fade
.background(Color.Black)
) {
// Pager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
userScrollEnabled = currentTool == EditorTool.NONE
) { page ->
Box(
Box(
modifier =
Modifier.fillMaxSize()
.graphicsLayer { alpha = animationProgress.value }
) {
// Pager
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
PhotoEditorView(ctx).apply {
photoEditorViews[page] = this
setPadding(0, 0, 0, 0)
setBackgroundColor(android.graphics.Color.BLACK)
// Загружаем изображение
scope.launch(Dispatchers.IO) {
try {
val inputStream = ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
withContext(Dispatchers.Main) {
source.apply {
setImageBitmap(bitmap)
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
setPadding(0, 0, 0, 0)
}
val editor = PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true)
.build()
photoEditors[page] = editor
}
} catch (e: Exception) {
// Handle error
}
}
}
},
userScrollEnabled = currentTool == EditorTool.NONE
) { page ->
Box(
modifier = Modifier.fillMaxSize(),
update = { view ->
val currentUri = imagesWithCaptions.getOrNull(page)?.uri
if (currentUri != null) {
scope.launch(Dispatchers.IO) {
try {
val inputStream = context.contentResolver.openInputStream(currentUri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
contentAlignment = Alignment.Center
) {
AndroidView(
factory = { ctx ->
PhotoEditorView(ctx).apply {
photoEditorViews[page] = this
withContext(Dispatchers.Main) {
view.source.setImageBitmap(bitmap)
view.source.scaleType = ImageView.ScaleType.FIT_CENTER
setPadding(0, 0, 0, 0)
setBackgroundColor(android.graphics.Color.BLACK)
// Загружаем изображение
scope.launch(Dispatchers.IO) {
try {
val inputStream =
ctx.contentResolver.openInputStream(imagesWithCaptions[page].uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
withContext(Dispatchers.Main) {
source.apply {
setImageBitmap(bitmap)
scaleType = ImageView.ScaleType.FIT_CENTER
adjustViewBounds = true
setPadding(0, 0, 0, 0)
}
val editor = PhotoEditor.Builder(ctx, this@apply)
.setPinchTextScalable(true)
.build()
photoEditors[page] = editor
}
} catch (e: Exception) {
// Handle error
}
}
}
},
modifier = Modifier.fillMaxSize(),
update = { view ->
val currentUri = imagesWithCaptions.getOrNull(page)?.uri
if (currentUri != null) {
scope.launch(Dispatchers.IO) {
try {
val inputStream =
context.contentResolver.openInputStream(currentUri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
withContext(Dispatchers.Main) {
view.source.setImageBitmap(bitmap)
view.source.scaleType = ImageView.ScaleType.FIT_CENTER
}
} catch (e: Exception) {
// Handle error
}
} catch (e: Exception) {
// Handle error
}
}
}
}
)
)
}
}
}
// Top bar
Box(
@@ -1789,19 +1734,19 @@ fun MultiImageEditorScreen(
}
// Bottom section - без imePadding, фото не сжимается
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
)
) {
) {
// Color picker
AnimatedVisibility(
visible = showColorPicker && currentTool == EditorTool.DRAW,
@@ -1956,7 +1901,8 @@ fun MultiImageEditorScreen(
}
}
Spacer(modifier = Modifier.navigationBarsPadding())
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}

View File

@@ -618,14 +618,22 @@ fun MediaPickerBottomSheet(
animatedClose()
onOpenCamera()
},
onItemClick = { item, position ->
// Telegram-style: клик на фото сразу открывает редактор
onItemClick = { item, _ ->
// Telegram-style selection:
// Tap toggles selection for both photos and videos.
if (item.id in selectedItems) {
selectedItems = selectedItems - item.id
} else if (selectedItems.size < maxSelection) {
selectedItems = selectedItems + item.id
}
},
onItemLongClick = { item ->
// Long press keeps quick edit for photos.
if (!item.isVideo) {
thumbnailPosition = position
// Сразу открываем редактор - галерея закроется автоматически
thumbnailPosition = null
editingItem = item
} else {
// Для видео - добавляем/убираем из selection
// Videos: keep long-press toggle behavior.
if (item.id in selectedItems) {
selectedItems = selectedItems - item.id
} else if (selectedItems.size < maxSelection) {
@@ -633,14 +641,6 @@ fun MediaPickerBottomSheet(
}
}
},
onItemLongClick = { item ->
// Long press - снять выделение если выбрана
if (item.id in selectedItems) {
selectedItems = selectedItems - item.id
} else if (selectedItems.size < maxSelection) {
selectedItems = selectedItems + item.id
}
},
isDarkTheme = isDarkTheme,
modifier = Modifier.weight(1f)
)

View File

@@ -1372,17 +1372,20 @@ private fun TelegramTextField(
targetValue = if (hasError) errorColor else Color.Transparent,
label = "profile_field_border_color"
)
val fieldModifier =
if (hasError) {
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(containerColor)
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 8.dp)
} else {
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp)
}
Column {
Column(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(12.dp))
.background(containerColor)
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Column(modifier = fieldModifier) {
if (isEditable && onValueChange != null) {
BasicTextField(
value = value,

View File

@@ -0,0 +1,74 @@
package com.rosetta.messenger.ui.utils
import android.content.Context
import android.graphics.Color
import android.view.View
import android.view.Window
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
data class SystemBarsState(
val statusBarColor: Int,
val navigationBarColor: Int,
val isLightStatusBars: Boolean,
val isLightNavigationBars: Boolean
)
object SystemBarsStyleUtils {
fun capture(window: Window?, view: View?): SystemBarsState? {
if (window == null || view == null) return null
val insetsController = WindowCompat.getInsetsController(window, view)
return SystemBarsState(
statusBarColor = window.statusBarColor,
navigationBarColor = window.navigationBarColor,
isLightStatusBars = insetsController.isAppearanceLightStatusBars,
isLightNavigationBars = insetsController.isAppearanceLightNavigationBars
)
}
fun applyFullscreenDark(window: Window?, view: View?) {
if (window == null || view == null) return
val insetsController = WindowCompat.getInsetsController(window, view)
window.statusBarColor = Color.BLACK
window.navigationBarColor = Color.BLACK
insetsController.isAppearanceLightStatusBars = false
insetsController.isAppearanceLightNavigationBars = false
}
fun applyChatStatusBar(window: Window?, view: View?, isDarkTheme: Boolean) {
if (window == null || view == null) return
val insetsController = WindowCompat.getInsetsController(window, view)
window.statusBarColor = Color.TRANSPARENT
insetsController.isAppearanceLightStatusBars = !isDarkTheme
}
fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {
if (window == null || view == null) return
val insetsController = WindowCompat.getInsetsController(window, view)
if (NavigationModeUtils.hasNativeNavigationBar(context)) {
insetsController.show(WindowInsetsCompat.Type.navigationBars())
if (state != null) {
window.navigationBarColor = state.navigationBarColor
insetsController.isAppearanceLightNavigationBars = state.isLightNavigationBars
}
} else {
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
fun restoreChatAfterFullscreen(
window: Window?,
view: View?,
context: Context,
isDarkTheme: Boolean,
state: SystemBarsState?
) {
applyChatStatusBar(window, view, isDarkTheme)
restoreNavigationBar(window, view, context, state)
}
}