Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user