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

View File

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