Refactor code structure for improved readability and maintainability

This commit is contained in:
k1ngsterr1
2026-02-08 06:18:20 +05:00
parent 0d0e1e2c22
commit 162747ea35
3 changed files with 2640 additions and 2309 deletions

View File

@@ -4,7 +4,6 @@ import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -21,6 +20,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
@@ -29,12 +29,12 @@ import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.network.PacketPushNotification
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.PushNotificationAction
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.chats.ChatDetailScreen
@@ -42,6 +42,7 @@ import com.rosetta.messenger.ui.chats.ChatsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.components.SwipeBackContainer
import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.settings.BackupScreen
import com.rosetta.messenger.ui.settings.BiometricEnableScreen
@@ -50,7 +51,6 @@ import com.rosetta.messenger.ui.settings.ProfileScreen
import com.rosetta.messenger.ui.settings.SafetyScreen
import com.rosetta.messenger.ui.settings.ThemeScreen
import com.rosetta.messenger.ui.settings.UpdatesScreen
import com.rosetta.messenger.ui.crashlogs.CrashLogsScreen
import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import java.text.SimpleDateFormat
@@ -58,7 +58,6 @@ import java.util.Date
import java.util.Locale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.fragment.app.FragmentActivity
class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager
@@ -134,7 +133,8 @@ class MainActivity : FragmentActivity() {
val scope = rememberCoroutineScope()
val themeMode by preferencesManager.themeMode.collectAsState(initial = "dark")
val systemInDarkTheme = isSystemInDarkTheme()
val isDarkTheme = when (themeMode) {
val isDarkTheme =
when (themeMode) {
"light" -> false
"dark" -> true
"auto" -> systemInDarkTheme
@@ -315,9 +315,7 @@ class MainActivity : FragmentActivity() {
}
},
onThemeModeChange = { mode ->
scope.launch {
preferencesManager.setThemeMode(mode)
}
scope.launch { preferencesManager.setThemeMode(mode) }
},
onLogout = {
// Set currentAccount to null immediately to prevent UI
@@ -343,10 +341,13 @@ class MainActivity : FragmentActivity() {
.filter { it.isNotEmpty() }
.let { words ->
when {
words.isEmpty() -> "??"
words.isEmpty() ->
"??"
words.size == 1 ->
words[0]
.take(2)
.take(
2
)
.uppercase()
else ->
"${words[0].first()}${words[1].first()}".uppercase()
@@ -475,6 +476,27 @@ class MainActivity : FragmentActivity() {
}
}
/**
* Navigation sealed class — replaces ~15 boolean flags with a type-safe navigation stack. ChatsList
* is always the base layer and is not part of the stack. Each SwipeBackContainer reads a
* derivedStateOf, so pushing/popping an unrelated screen won't trigger recomposition of other
* screens.
*/
sealed class Screen {
data object Profile : Screen()
data object Search : Screen()
data class ChatDetail(val user: SearchUser) : Screen()
data class OtherProfile(val user: SearchUser) : Screen()
data object Updates : Screen()
data object Theme : Screen()
data object Safety : Screen()
data object Backup : Screen()
data object Logs : Screen()
data object CrashLogs : Screen()
data object Biometric : Screen()
data object Appearance : Screen()
}
@Composable
fun MainScreen(
account: DecryptedAccount? = null,
@@ -518,7 +540,10 @@ fun MainScreen(
// Перечитать username/name после получения own profile с сервера
// Аналог Desktop: useUserInformation автоматически обновляет UI при PacketSearch ответе
LaunchedEffect(protocolState) {
if (protocolState == ProtocolState.AUTHENTICATED && accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
if (protocolState == ProtocolState.AUTHENTICATED &&
accountPublicKey.isNotBlank() &&
accountPublicKey != "04c266b98ae5"
) {
delay(2000) // Ждём fetchOwnProfile() → PacketSearch → AccountManager update
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountPublicKey)
@@ -527,34 +552,80 @@ fun MainScreen(
}
}
// Навигация между экранами
var selectedUser by remember { mutableStateOf<SearchUser?>(null) }
var showSearchScreen by remember { mutableStateOf(false) }
var showProfileScreen by remember { mutableStateOf(false) }
var showOtherProfileScreen by remember { mutableStateOf(false) }
var selectedOtherUser by remember { mutableStateOf<SearchUser?>(null) }
// ═══════════════════════════════════════════════════════════
// Navigation stack — sealed class instead of ~15 boolean flags.
// ChatsList is always the base layer (not in stack).
// Each derivedStateOf only recomposes its SwipeBackContainer
// when that specific screen appears/disappears — not on every
// navigation change. This eliminates the massive recomposition
// that happened when ANY boolean flag changed.
// ═══════════════════════════════════════════════════════════
var navStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
// Дополнительные экраны настроек
var showUpdatesScreen by remember { mutableStateOf(false) }
var showThemeScreen by remember { mutableStateOf(false) }
var showSafetyScreen by remember { mutableStateOf(false) }
var showBackupScreen by remember { mutableStateOf(false) }
var showLogsScreen by remember { mutableStateOf(false) }
var showCrashLogsScreen by remember { mutableStateOf(false) }
var showBiometricScreen by remember { mutableStateOf(false) }
var showAppearanceScreen by remember { mutableStateOf(false) }
// Derived visibility — only triggers recomposition when THIS screen changes
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val chatDetailScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.ChatDetail>().lastOrNull() }
}
val selectedUser = chatDetailScreen?.user
val otherProfileScreen by remember {
derivedStateOf { navStack.filterIsInstance<Screen.OtherProfile>().lastOrNull() }
}
val selectedOtherUser = otherProfileScreen?.user
val isUpdatesVisible by remember { derivedStateOf { navStack.any { it is Screen.Updates } } }
val isThemeVisible by remember { derivedStateOf { navStack.any { it is Screen.Theme } } }
val isSafetyVisible by remember { derivedStateOf { navStack.any { it is Screen.Safety } } }
val isBackupVisible by remember { derivedStateOf { navStack.any { it is Screen.Backup } } }
val isLogsVisible by remember { derivedStateOf { navStack.any { it is Screen.Logs } } }
val isCrashLogsVisible by remember {
derivedStateOf { navStack.any { it is Screen.CrashLogs } }
}
val isBiometricVisible by remember {
derivedStateOf { navStack.any { it is Screen.Biometric } }
}
val isAppearanceVisible by remember {
derivedStateOf { navStack.any { it is Screen.Appearance } }
}
// Navigation helpers
fun pushScreen(screen: Screen) {
navStack = navStack + screen
}
fun popScreen() {
navStack = navStack.dropLast(1)
}
fun popProfileAndChildren() {
navStack =
navStack.filterNot {
it is Screen.Profile ||
it is Screen.Theme ||
it is Screen.Safety ||
it is Screen.Backup ||
it is Screen.Logs ||
it is Screen.CrashLogs ||
it is Screen.Biometric ||
it is Screen.Appearance
}
}
fun popChatAndChildren() {
navStack = navStack.filterNot { it is Screen.ChatDetail || it is Screen.OtherProfile }
}
// ProfileViewModel для логов
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
androidx.lifecycle.viewmodel.compose.viewModel()
val profileState by profileViewModel.state.collectAsState()
// Appearance: background blur color preference
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val backgroundBlurColorId by prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar")
val backgroundBlurColorId by
prefsManager.backgroundBlurColorId.collectAsState(initial = "avatar")
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
// AvatarRepository для работы с аватарами
val avatarRepository = remember(accountPublicKey) {
val avatarRepository =
remember(accountPublicKey) {
if (accountPublicKey.isNotBlank() && accountPublicKey != "04c266b98ae5") {
val database = RosettaDatabase.getDatabase(context)
AvatarRepository(
@@ -582,9 +653,7 @@ fun MainScreen(
accountPrivateKey = accountPrivateKey,
privateKeyHash = privateKeyHash,
onToggleTheme = onToggleTheme,
onProfileClick = {
showProfileScreen = true
},
onProfileClick = { pushScreen(Screen.Profile) },
onNewGroupClick = {
// TODO: Navigate to new group
},
@@ -596,7 +665,8 @@ fun MainScreen(
},
onSavedMessagesClick = {
// Открываем чат с самим собой (Saved Messages)
selectedUser =
pushScreen(
Screen.ChatDetail(
SearchUser(
title = "Saved Messages",
username = "",
@@ -604,22 +674,24 @@ fun MainScreen(
verified = 0,
online = 1
)
)
)
},
onSettingsClick = { showProfileScreen = true },
onSettingsClick = { pushScreen(Screen.Profile) },
onInviteFriendsClick = {
// TODO: Share invite link
},
onSearchClick = { showSearchScreen = true },
onSearchClick = { pushScreen(Screen.Search) },
onNewChat = {
// TODO: Show new chat screen
},
onUserSelect = { selectedChatUser -> selectedUser = selectedChatUser },
onUserSelect = { selectedChatUser ->
pushScreen(Screen.ChatDetail(selectedChatUser))
},
backgroundBlurColorId = backgroundBlurColorId,
pinnedChats = pinnedChats,
onTogglePin = { opponentKey ->
mainScreenScope.launch {
prefsManager.togglePinChat(opponentKey)
}
mainScreenScope.launch { prefsManager.togglePinChat(opponentKey) }
},
avatarRepository = avatarRepository,
onLogout = onLogout
@@ -630,8 +702,8 @@ fun MainScreen(
// visible beneath them during swipe-back animation
// ═══════════════════════════════════════════════════════════
SwipeBackContainer(
isVisible = showProfileScreen,
onBack = { showProfileScreen = false },
isVisible = isProfileVisible,
onBack = { popProfileAndChildren() },
isDarkTheme = isDarkTheme
) {
// Экран профиля
@@ -641,33 +713,19 @@ fun MainScreen(
accountUsername = accountUsername,
accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash,
onBack = { showProfileScreen = false },
onBack = { popProfileAndChildren() },
onSaveProfile = { name, username ->
accountName = name
accountUsername = username
mainScreenScope.launch {
onAccountInfoUpdated()
}
mainScreenScope.launch { onAccountInfoUpdated() }
},
onLogout = onLogout,
onNavigateToTheme = {
showThemeScreen = true
},
onNavigateToAppearance = {
showAppearanceScreen = true
},
onNavigateToSafety = {
showSafetyScreen = true
},
onNavigateToLogs = {
showLogsScreen = true
},
onNavigateToCrashLogs = {
showCrashLogsScreen = true
},
onNavigateToBiometric = {
showBiometricScreen = true
},
onNavigateToTheme = { pushScreen(Screen.Theme) },
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) },
onNavigateToLogs = { pushScreen(Screen.Logs) },
onNavigateToCrashLogs = { pushScreen(Screen.CrashLogs) },
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
viewModel = profileViewModel,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
@@ -677,18 +735,14 @@ fun MainScreen(
// Other screens with swipe back
SwipeBackContainer(
isVisible = showBackupScreen,
onBack = {
showBackupScreen = false
showSafetyScreen = true
},
isVisible = isBackupVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Backup } + Screen.Safety },
isDarkTheme = isDarkTheme
) {
BackupScreen(
isDarkTheme = isDarkTheme,
onBack = {
showBackupScreen = false
showSafetyScreen = true
navStack = navStack.filterNot { it is Screen.Backup } + Screen.Safety
},
onVerifyPassword = { password ->
// Verify password by trying to decrypt the private key
@@ -699,7 +753,9 @@ fun MainScreen(
if (encryptedAccount != null) {
// Try to decrypt private key with password
val decryptedPrivateKey = com.rosetta.messenger.crypto.CryptoManager.decryptWithPassword(
val decryptedPrivateKey =
com.rosetta.messenger.crypto.CryptoManager
.decryptWithPassword(
encryptedAccount.encryptedPrivateKey,
password
)
@@ -724,24 +780,17 @@ fun MainScreen(
}
SwipeBackContainer(
isVisible = showSafetyScreen,
onBack = {
showSafetyScreen = false
showProfileScreen = true
},
isVisible = isSafetyVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
isDarkTheme = isDarkTheme
) {
SafetyScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
onBack = {
showSafetyScreen = false
showProfileScreen = true
},
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
onBackupClick = {
showSafetyScreen = false
showBackupScreen = true
navStack = navStack.filterNot { it is Screen.Safety } + Screen.Backup
},
onDeleteAccount = {
// TODO: Implement account deletion
@@ -750,43 +799,29 @@ fun MainScreen(
}
SwipeBackContainer(
isVisible = showThemeScreen,
onBack = {
showThemeScreen = false
showProfileScreen = true
},
isVisible = isThemeVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
isDarkTheme = isDarkTheme
) {
ThemeScreen(
isDarkTheme = isDarkTheme,
currentThemeMode = themeMode,
onBack = {
showThemeScreen = false
showProfileScreen = true
},
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
onThemeModeChange = onThemeModeChange
)
}
SwipeBackContainer(
isVisible = showAppearanceScreen,
onBack = {
showAppearanceScreen = false
showProfileScreen = true
},
isVisible = isAppearanceVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
isDarkTheme = isDarkTheme
) {
com.rosetta.messenger.ui.settings.AppearanceScreen(
isDarkTheme = isDarkTheme,
currentBlurColorId = backgroundBlurColorId,
onBack = {
showAppearanceScreen = false
showProfileScreen = true
},
onBack = { navStack = navStack.filterNot { it is Screen.Appearance } },
onBlurColorChange = { newId ->
mainScreenScope.launch {
prefsManager.setBackgroundBlurColorId(newId)
}
mainScreenScope.launch { prefsManager.setBackgroundBlurColorId(newId) }
},
onToggleTheme = onToggleTheme,
accountPublicKey = accountPublicKey,
@@ -796,13 +831,13 @@ fun MainScreen(
}
SwipeBackContainer(
isVisible = showUpdatesScreen,
onBack = { showUpdatesScreen = false },
isVisible = isUpdatesVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Updates } },
isDarkTheme = isDarkTheme
) {
UpdatesScreen(
isDarkTheme = isDarkTheme,
onBack = { showUpdatesScreen = false }
onBack = { navStack = navStack.filterNot { it is Screen.Updates } }
)
}
@@ -811,38 +846,38 @@ fun MainScreen(
SwipeBackContainer(
isVisible = selectedUser != null,
onBack = { selectedUser = null },
onBack = { popChatAndChildren() },
isDarkTheme = isDarkTheme,
swipeEnabled = !isImageViewerOpen
) {
if (selectedUser != null) {
selectedUser?.let { currentChatUser ->
// Экран чата
ChatDetailScreen(
user = selectedUser!!,
user = currentChatUser,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
onBack = { selectedUser = null },
onBack = { popChatAndChildren() },
onUserProfileClick = { user ->
// Открываем профиль другого пользователя
selectedOtherUser = user
showOtherProfileScreen = true
pushScreen(Screen.OtherProfile(user))
},
onNavigateToChat = { forwardUser ->
// 📨 Forward: переход в выбранный чат с полными данными
selectedUser = forwardUser
navStack =
navStack.filterNot {
it is Screen.ChatDetail || it is Screen.OtherProfile
} + Screen.ChatDetail(forwardUser)
},
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onImageViewerChanged = { isOpen ->
isImageViewerOpen = isOpen
}
onImageViewerChanged = { isOpen -> isImageViewerOpen = isOpen }
)
}
}
SwipeBackContainer(
isVisible = showSearchScreen,
onBack = { showSearchScreen = false },
isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme
) {
// Экран поиска
@@ -851,67 +886,48 @@ fun MainScreen(
currentUserPublicKey = accountPublicKey,
isDarkTheme = isDarkTheme,
protocolState = protocolState,
onBackClick = { showSearchScreen = false },
onBackClick = { navStack = navStack.filterNot { it is Screen.Search } },
onUserSelect = { selectedSearchUser ->
showSearchScreen = false
selectedUser = selectedSearchUser
navStack =
navStack.filterNot { it is Screen.Search } +
Screen.ChatDetail(selectedSearchUser)
}
)
}
SwipeBackContainer(
isVisible = showLogsScreen,
onBack = {
showLogsScreen = false
showProfileScreen = true
},
isVisible = isLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Logs } },
isDarkTheme = isDarkTheme
) {
com.rosetta.messenger.ui.settings.ProfileLogsScreen(
isDarkTheme = isDarkTheme,
logs = profileState.logs,
onBack = {
showLogsScreen = false
showProfileScreen = true
},
onClearLogs = {
profileViewModel.clearLogs()
}
onBack = { navStack = navStack.filterNot { it is Screen.Logs } },
onClearLogs = { profileViewModel.clearLogs() }
)
}
SwipeBackContainer(
isVisible = showCrashLogsScreen,
onBack = {
showCrashLogsScreen = false
showProfileScreen = true
},
isVisible = isCrashLogsVisible,
onBack = { navStack = navStack.filterNot { it is Screen.CrashLogs } },
isDarkTheme = isDarkTheme
) {
CrashLogsScreen(
onBackClick = {
showCrashLogsScreen = false
showProfileScreen = true
}
onBackClick = { navStack = navStack.filterNot { it is Screen.CrashLogs } }
)
}
SwipeBackContainer(
isVisible = showOtherProfileScreen,
onBack = {
showOtherProfileScreen = false
selectedOtherUser = null
},
isVisible = selectedOtherUser != null,
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
isDarkTheme = isDarkTheme
) {
if (selectedOtherUser != null) {
selectedOtherUser?.let { currentOtherUser ->
OtherProfileScreen(
user = selectedOtherUser!!,
user = currentOtherUser,
isDarkTheme = isDarkTheme,
onBack = {
showOtherProfileScreen = false
selectedOtherUser = null
},
onBack = { navStack = navStack.filterNot { it is Screen.OtherProfile } },
avatarRepository = avatarRepository
)
}
@@ -919,23 +935,21 @@ fun MainScreen(
// Biometric Enable Screen
SwipeBackContainer(
isVisible = showBiometricScreen,
onBack = {
showBiometricScreen = false
showProfileScreen = true
},
isVisible = isBiometricVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Biometric } },
isDarkTheme = isDarkTheme
) {
val biometricManager = remember { com.rosetta.messenger.biometric.BiometricAuthManager(context) }
val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) }
val biometricManager = remember {
com.rosetta.messenger.biometric.BiometricAuthManager(context)
}
val biometricPrefs = remember {
com.rosetta.messenger.biometric.BiometricPreferences(context)
}
val activity = context as? FragmentActivity
BiometricEnableScreen(
isDarkTheme = isDarkTheme,
onBack = {
showBiometricScreen = false
showProfileScreen = true
},
onBack = { navStack = navStack.filterNot { it is Screen.Biometric } },
onEnable = { password, onSuccess, onError ->
if (activity == null) {
onError("Activity not available")
@@ -947,15 +961,17 @@ fun MainScreen(
password = password,
onSuccess = { encryptedPassword ->
mainScreenScope.launch {
biometricPrefs.saveEncryptedPassword(accountPublicKey, encryptedPassword)
biometricPrefs.saveEncryptedPassword(
accountPublicKey,
encryptedPassword
)
biometricPrefs.enableBiometric()
onSuccess()
}
},
onError = { error -> onError(error) },
onCancel = {
showBiometricScreen = false
showProfileScreen = true
navStack = navStack.filterNot { it is Screen.Biometric }
}
)
}

View File

@@ -1,13 +1,15 @@
package com.rosetta.messenger.ui.chats
import android.content.ClipboardManager
import android.app.Activity
import android.content.Context
import android.net.Uri
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
@@ -16,7 +18,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -28,6 +29,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -35,11 +37,10 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -51,52 +52,41 @@ 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
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.input.*
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.input.*
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.ui.chats.ForwardChatPickerBottomSheet
import com.rosetta.messenger.utils.MediaUtils
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.MultiImageEditorScreen
import com.rosetta.messenger.ui.chats.components.ImageWithCaption
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import androidx.compose.runtime.collectAsState
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.net.Uri
import android.provider.MediaStore
import androidx.core.content.FileProvider
import java.io.File
import kotlinx.coroutines.delay
import android.app.Activity
import androidx.core.view.WindowCompat
import java.text.SimpleDateFormat
import java.util.Locale
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(
ExperimentalMaterial3Api::class,
@@ -224,7 +214,9 @@ fun ChatDetailScreen(
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
// 📷 Состояние для flow камеры: фото → редактор с caption → отправка
var pendingCameraPhotoUri by remember { mutableStateOf<Uri?>(null) } // Фото для редактирования
var pendingCameraPhotoUri by remember {
mutableStateOf<Uri?>(null)
} // Фото для редактирования
// 📷 Показать встроенную камеру (без системного превью)
var showInAppCamera by remember { mutableStateOf(false) }
@@ -233,7 +225,8 @@ fun ChatDetailScreen(
var pendingGalleryImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
// <20>📷 Camera launcher
val cameraLauncher = rememberLauncherForActivityResult(
val cameraLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success && cameraImageUri != null) {
@@ -246,7 +239,8 @@ fun ChatDetailScreen(
}
// 📄 File picker launcher
val filePickerLauncher = rememberLauncherForActivityResult(
val filePickerLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
@@ -260,19 +254,23 @@ fun ChatDetailScreen(
context,
"Файл слишком большой (макс. ${MediaUtils.MAX_FILE_SIZE_MB} МБ)",
android.widget.Toast.LENGTH_LONG
).show()
)
.show()
return@launch
}
val base64 = MediaUtils.uriToBase64File(context, uri)
if (base64 != null) {
viewModel.sendFileMessage(base64, fileName, fileSize)
viewModel.sendFileMessage(
base64,
fileName,
fileSize
)
}
}
}
}
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
@@ -295,12 +293,11 @@ fun ChatDetailScreen(
val debugLogs by ProtocolManager.debugLogs.collectAsState()
// Включаем UI логи только когда открыт bottom sheet
LaunchedEffect(showDebugLogs) {
ProtocolManager.enableUILogs(showDebugLogs)
}
LaunchedEffect(showDebugLogs) { ProtocolManager.enableUILogs(showDebugLogs) }
// Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by database.blacklistDao()
val isBlocked by
database.blacklistDao()
.observeUserBlocked(user.publicKey, currentUserPublicKey)
.collectAsState(initial = false)
@@ -312,7 +309,6 @@ fun ChatDetailScreen(
val isOnline by viewModel.opponentOnline.collectAsState()
val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона
// <20>🔥 Reply/Forward state
val replyMessages by viewModel.replyMessages.collectAsState()
val hasReply = replyMessages.isNotEmpty()
@@ -328,14 +324,18 @@ fun ChatDetailScreen(
snapshotFlow {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
val lastVisibleItemIndex =
layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
Pair(lastVisibleItemIndex, totalItems)
}
.distinctUntilChanged()
.debounce(100) // 🔥 Debounce чтобы не триггерить на каждый фрейм скролла
.collect { (lastVisible, total) ->
// Загружаем когда осталось 5 элементов до конца и не идёт загрузка
if (total > 0 && lastVisible >= total - 5 && !viewModel.isLoadingMore.value) {
if (total > 0 &&
lastVisible >= total - 5 &&
!viewModel.isLoadingMore.value
) {
viewModel.loadMoreMessages()
}
}
@@ -351,53 +351,18 @@ fun ChatDetailScreen(
text = chatMsg.text,
timestamp = chatMsg.timestamp.time,
isOutgoing = chatMsg.isOutgoing,
publicKey = if (chatMsg.isOutgoing) currentUserPublicKey else user.publicKey,
publicKey =
if (chatMsg.isOutgoing) currentUserPublicKey
else user.publicKey,
attachments = chatMsg.attachments
)
}
}
}
// 🔥 Добавляем информацию о датах к сообщениям
// В reversed layout (новые внизу) - показываем дату ПОСЛЕ сообщения (визуально сверху)
val messagesWithDates =
remember(messages) {
val result =
mutableListOf<
Pair<ChatMessage, Boolean>>() // message, showDateHeader
var lastDateString = ""
// 🔥 КРИТИЧНО: Дедупликация по ID перед сортировкой!
val uniqueMessages = messages.distinctBy { it.id }
if (uniqueMessages.size != messages.size) {
}
// Сортируем по времени (новые -> старые) для reversed layout
val sortedMessages = uniqueMessages.sortedByDescending { it.timestamp.time }
for (i in sortedMessages.indices) {
val message = sortedMessages[i]
val dateString =
SimpleDateFormat("yyyyMMdd", Locale.getDefault())
.format(message.timestamp)
// Показываем дату если это последнее сообщение за день
// (следующее сообщение - другой день или нет следующего)
val nextMessage = sortedMessages.getOrNull(i + 1)
val nextDateString =
nextMessage?.let {
SimpleDateFormat("yyyyMMdd", Locale.getDefault())
.format(it.timestamp)
}
val showDate =
nextDateString == null || nextDateString != dateString
result.add(message to showDate)
lastDateString = dateString
}
result
}
// 🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// (dedup + sort + date headers off the main thread)
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
// 🔥 Функция для скролла к сообщению с подсветкой
val scrollToMessage: (String) -> Unit = { messageId ->
@@ -492,7 +457,10 @@ fun ChatDetailScreen(
// 🔥 Скроллим только если изменился ID самого нового сообщения
// При пагинации добавляются старые сообщения - ID нового не меняется
LaunchedEffect(newestMessageId) {
if (newestMessageId != null && lastNewestMessageId != null && newestMessageId != lastNewestMessageId) {
if (newestMessageId != null &&
lastNewestMessageId != null &&
newestMessageId != lastNewestMessageId
) {
// Новое сообщение пришло - скроллим вниз
delay(50) // Debounce - ждём стабилизации
listState.animateScrollToItem(0)
@@ -509,9 +477,7 @@ fun ChatDetailScreen(
)
// 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer)
Box(
modifier = Modifier.fillMaxSize()
) {
Box(modifier = Modifier.fillMaxSize()) {
// Telegram-style solid header background (без blur)
val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF)
@@ -529,7 +495,8 @@ fun ChatDetailScreen(
else Color.White
} else headerBackground
)
// 🎨 statusBarsPadding ПОСЛЕ background = хедер начинается под статус баром
// 🎨 statusBarsPadding ПОСЛЕ background =
// хедер начинается под статус баром
.statusBarsPadding()
) {
// Контент хедера с Crossfade для плавной смены - ускоренная
@@ -745,7 +712,13 @@ fun ChatDetailScreen(
contentDescription =
"Back",
tint =
if (isDarkTheme) Color.White else Color(0xFF007AFF),
if (isDarkTheme
)
Color.White
else
Color(
0xFF007AFF
),
modifier =
Modifier.size(
32.dp
@@ -824,29 +797,52 @@ fun ChatDetailScreen(
modifier =
Modifier.size(40.dp)
.then(
if (!isSavedMessages) {
Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
if (!isSavedMessages
) {
Modifier
.clickable(
indication =
null,
interactionSource =
remember {
MutableInteractionSource()
}
) {
// Мгновенное закрытие клавиатуры через нативный API
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
onUserProfileClick(user)
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
onUserProfileClick(
user
)
}
} else Modifier
} else
Modifier
),
contentAlignment =
Alignment.Center
) {
if (isSavedMessages) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center
modifier =
Modifier.fillMaxSize()
.clip(
CircleShape
)
.background(
PrimaryBlue
),
contentAlignment =
Alignment
.Center
) {
Icon(
Icons.Default
@@ -863,11 +859,19 @@ fun ChatDetailScreen(
}
} else {
AvatarImage(
publicKey = user.publicKey,
avatarRepository = avatarRepository,
size = 40.dp,
isDarkTheme = isDarkTheme,
displayName = user.title.ifEmpty { user.username } // 🔥 Для инициалов
publicKey =
user.publicKey,
avatarRepository =
avatarRepository,
size =
40.dp,
isDarkTheme =
isDarkTheme,
displayName =
user.title
.ifEmpty {
user.username
} // 🔥 Для инициалов
)
}
}
@@ -884,18 +888,35 @@ fun ChatDetailScreen(
modifier =
Modifier.weight(1f)
.then(
if (!isSavedMessages) {
Modifier.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
if (!isSavedMessages
) {
Modifier
.clickable(
indication =
null,
interactionSource =
remember {
MutableInteractionSource()
}
) {
// Мгновенное закрытие клавиатуры через нативный API
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
onUserProfileClick(user)
val imm =
context.getSystemService(
Context.INPUT_METHOD_SERVICE
) as
InputMethodManager
imm.hideSoftInputFromWindow(
view.windowToken,
0
)
focusManager
.clearFocus()
onUserProfileClick(
user
)
}
} else Modifier
} else
Modifier
)
) {
Row(
@@ -934,7 +955,8 @@ fun ChatDetailScreen(
user.verified,
size =
16,
isDarkTheme = isDarkTheme
isDarkTheme =
isDarkTheme
)
}
}
@@ -980,7 +1002,13 @@ fun ChatDetailScreen(
contentDescription =
"Call",
tint =
if (isDarkTheme) Color.White else Color(0xFF007AFF)
if (isDarkTheme
)
Color.White
else
Color(
0xFF007AFF
)
)
}
}
@@ -1013,7 +1041,13 @@ fun ChatDetailScreen(
contentDescription =
"More",
tint =
if (isDarkTheme) Color.White else Color(0xFF007AFF),
if (isDarkTheme
)
Color.White
else
Color(
0xFF007AFF
),
modifier =
Modifier.size(
26.dp
@@ -1164,10 +1198,14 @@ fun ChatDetailScreen(
modifier =
Modifier.fillMaxWidth()
.padding(
start = 12.dp,
end = 12.dp,
top = 8.dp,
bottom = 16.dp
start =
12.dp,
end =
12.dp,
top =
8.dp,
bottom =
16.dp
)
.navigationBarsPadding()
.graphicsLayer {
@@ -1447,13 +1485,23 @@ fun ChatDetailScreen(
onReplyClick =
scrollToMessage,
onAttachClick = {
// Telegram-style: галерея открывается ПОВЕРХ клавиатуры
// НЕ скрываем клавиатуру!
showMediaPicker = true
// Telegram-style:
// галерея
// открывается
// ПОВЕРХ клавиатуры
// НЕ скрываем
// клавиатуру!
showMediaPicker =
true
},
myPublicKey = viewModel.myPublicKey ?: "",
opponentPublicKey = user.publicKey,
myPrivateKey = currentUserPrivateKey
myPublicKey =
viewModel
.myPublicKey
?: "",
opponentPublicKey =
user.publicKey,
myPrivateKey =
currentUserPrivateKey
)
}
}
@@ -1463,7 +1511,8 @@ fun ChatDetailScreen(
) { paddingValues ->
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
Box(modifier = Modifier.fillMaxSize()) {
// 🔥 Column структура - список сжимается когда клавиатура открывается
// 🔥 Column структура - список сжимается когда клавиатура
// открывается
Column(
modifier =
Modifier.fillMaxSize()
@@ -1478,7 +1527,8 @@ fun ChatDetailScreen(
isLoading -> {
MessageSkeletonList(
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()
modifier =
Modifier.fillMaxSize()
)
}
// Пустое состояние (нет сообщений)
@@ -1486,7 +1536,9 @@ fun ChatDetailScreen(
Column(
modifier =
Modifier.fillMaxSize()
.padding(32.dp),
.padding(
32.dp
),
horizontalAlignment =
Alignment
.CenterHorizontally,
@@ -1556,14 +1608,17 @@ fun ChatDetailScreen(
)
Text(
text =
if (isSavedMessages)
if (isSavedMessages
)
"Save messages here for quick access"
else
"No messages yet",
fontSize = 16.sp,
color = secondaryTextColor,
color =
secondaryTextColor,
fontWeight =
FontWeight.Medium
FontWeight
.Medium
)
Spacer(
modifier =
@@ -1573,7 +1628,8 @@ fun ChatDetailScreen(
)
Text(
text =
if (isSavedMessages)
if (isSavedMessages
)
"Forward messages here or send notes to yourself"
else
"Send a message to start the conversation",
@@ -1620,26 +1676,35 @@ fun ChatDetailScreen(
),
contentPadding =
PaddingValues(
start = 0.dp,
start =
0.dp,
end = 0.dp,
top = 8.dp,
bottom =
if (isSelectionMode
)
100.dp
else 16.dp
else
16.dp
),
reverseLayout = true
) {
itemsIndexed(
messagesWithDates,
key = { _, item ->
item.first.id
item.first
.id
}
) { index, (message, showDate) ->
// Определяем, показывать ли
// хвостик (последнее
// сообщение в группе)
) {
index,
(message, showDate)
->
// Определяем,
// показывать ли
// хвостик
// (последнее
// сообщение в
// группе)
val nextMessage =
messagesWithDates
.getOrNull(
@@ -1660,8 +1725,10 @@ fun ChatDetailScreen(
.time) >
60_000
// Определяем начало новой
// группы (для отступов)
// Определяем начало
// новой
// группы (для
// отступов)
val prevMessage =
messagesWithDates
.getOrNull(
@@ -1670,7 +1737,8 @@ fun ChatDetailScreen(
)
?.first
val isGroupStart =
prevMessage != null &&
prevMessage !=
null &&
(prevMessage
.isOutgoing !=
message.isOutgoing ||
@@ -1682,7 +1750,8 @@ fun ChatDetailScreen(
60_000)
Column {
if (showDate) {
if (showDate
) {
DateHeader(
dateText =
getDateText(
@@ -1717,14 +1786,22 @@ fun ChatDetailScreen(
privateKey =
currentUserPrivateKey,
senderPublicKey =
if (message.isOutgoing) currentUserPublicKey else user.publicKey,
if (message.isOutgoing
)
currentUserPublicKey
else
user.publicKey,
currentUserPublicKey =
currentUserPublicKey,
avatarRepository =
avatarRepository,
onLongClick = {
// 📳 Haptic feedback при долгом нажатии
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
hapticFeedback
.performHapticFeedback(
HapticFeedbackType
.LongPress
)
if (!isSelectionMode
) {
@@ -1774,8 +1851,15 @@ fun ChatDetailScreen(
},
onSwipeToReply = {
// Не разрешаем reply на сообщения с аватаркой
val hasAvatar = message.attachments.any { it.type == AttachmentType.AVATAR }
if (!hasAvatar) {
val hasAvatar =
message.attachments
.any {
it.type ==
AttachmentType
.AVATAR
}
if (!hasAvatar
) {
viewModel
.setReplyMessages(
listOf(
@@ -1803,18 +1887,33 @@ fun ChatDetailScreen(
message.id
)
},
onImageClick = { attachmentId, bounds ->
onImageClick = {
attachmentId,
bounds
->
// 📸 Открыть просмотрщик фото с shared element animation
val allImages = extractImagesFromMessages(
val allImages =
extractImagesFromMessages(
messages,
currentUserPublicKey,
user.publicKey,
user.title.ifEmpty { "User" }
user.title
.ifEmpty {
"User"
}
)
imageViewerInitialIndex =
findImageIndex(
allImages,
attachmentId
)
imageViewerSourceBounds =
bounds
showImageViewer =
true
onImageViewerChanged(
true
)
imageViewerInitialIndex = findImageIndex(allImages, attachmentId)
imageViewerSourceBounds = bounds
showImageViewer = true
onImageViewerChanged(true)
}
)
}
@@ -1834,9 +1933,8 @@ fun ChatDetailScreen(
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia ->
// 📸 Открываем edit screen для выбранных изображений
val imageUris = selectedMedia
.filter { !it.isVideo }
.map { it.uri }
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
pendingGalleryImages = imageUris
@@ -1846,11 +1944,29 @@ fun ChatDetailScreen(
// 📸 Отправляем фото с caption напрямую
showMediaPicker = false
scope.launch {
val base64 = MediaUtils.uriToBase64Image(context, mediaItem.uri)
val blurhash = MediaUtils.generateBlurhash(context, mediaItem.uri)
val (width, height) = MediaUtils.getImageDimensions(context, mediaItem.uri)
val base64 =
MediaUtils.uriToBase64Image(
context,
mediaItem.uri
)
val blurhash =
MediaUtils.generateBlurhash(
context,
mediaItem.uri
)
val (width, height) =
MediaUtils.getImageDimensions(
context,
mediaItem.uri
)
if (base64 != null) {
viewModel.sendImageMessage(base64, blurhash, caption, width, height)
viewModel.sendImageMessage(
base64,
blurhash,
caption,
width,
height
)
}
}
},
@@ -1875,7 +1991,8 @@ fun ChatDetailScreen(
// 📸 Image Viewer Overlay with Telegram-style shared element animation
if (showImageViewer) {
val allImages = extractImagesFromMessages(
val allImages =
extractImagesFromMessages(
messages,
currentUserPublicKey,
user.publicKey,
@@ -1894,7 +2011,8 @@ fun ChatDetailScreen(
// Сразу сбрасываем status bar при начале закрытия (до анимации)
window?.statusBarColor = android.graphics.Color.TRANSPARENT
window?.let { w ->
WindowCompat.getInsetsController(w, view)?.isAppearanceLightStatusBars = !isDarkTheme
WindowCompat.getInsetsController(w, view)
?.isAppearanceLightStatusBars = !isDarkTheme
}
},
isDarkTheme = isDarkTheme,
@@ -2106,7 +2224,8 @@ fun ChatDetailScreen(
onChatSelected = { selectedDialog ->
showForwardPicker = false
ForwardManager.selectChat(selectedDialog.opponentKey)
val searchUser = SearchUser(
val searchUser =
SearchUser(
title = selectedDialog.opponentTitle,
username = selectedDialog.opponentUsername,
publicKey = selectedDialog.opponentKey,
@@ -2134,9 +2253,7 @@ fun ChatDetailScreen(
pendingCameraPhotoUri?.let { uri ->
ImageEditorScreen(
imageUri = uri,
onDismiss = {
pendingCameraPhotoUri = null
},
onDismiss = { pendingCameraPhotoUri = null },
onSave = { editedUri ->
// 🚀 Мгновенный optimistic UI - фото появляется СРАЗУ
viewModel.sendImageFromUri(editedUri, "")
@@ -2156,13 +2273,14 @@ fun ChatDetailScreen(
if (pendingGalleryImages.isNotEmpty()) {
MultiImageEditorScreen(
imageUris = pendingGalleryImages,
onDismiss = {
pendingGalleryImages = emptyList()
},
onDismiss = { pendingGalleryImages = emptyList() },
onSendAll = { imagesWithCaptions ->
// 🚀 Мгновенный optimistic UI для каждого фото
for (imageWithCaption in imagesWithCaptions) {
viewModel.sendImageFromUri(imageWithCaption.uri, imageWithCaption.caption)
viewModel.sendImageFromUri(
imageWithCaption.uri,
imageWithCaption.caption
)
}
},
isDarkTheme = isDarkTheme,