Compare commits
4 Commits
ead84a8a53
...
b81b38f40d
| Author | SHA1 | Date | |
|---|---|---|---|
| b81b38f40d | |||
| 19508090a5 | |||
| 6d14881fa2 | |||
| 081bdb6d30 |
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.4.7"
|
val rosettaVersionName = "1.4.8"
|
||||||
val rosettaVersionCode = 49 // Increment on each release
|
val rosettaVersionCode = 50 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -67,6 +72,7 @@ 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
|
||||||
|
import com.rosetta.messenger.ui.settings.NotificationsScreen
|
||||||
import com.rosetta.messenger.ui.settings.OtherProfileScreen
|
import com.rosetta.messenger.ui.settings.OtherProfileScreen
|
||||||
import com.rosetta.messenger.ui.settings.ProfileScreen
|
import com.rosetta.messenger.ui.settings.ProfileScreen
|
||||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||||
@@ -203,6 +209,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
|
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
|
||||||
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
|
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
|
||||||
var startCreateAccountFlow by remember { mutableStateOf(false) }
|
var startCreateAccountFlow by remember { mutableStateOf(false) }
|
||||||
|
var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
|
||||||
|
var preservedMainNavAccountKey by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// Check for existing accounts and build AccountInfo list
|
// Check for existing accounts and build AccountInfo list
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -303,6 +311,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
startCreateAccountFlow = false
|
startCreateAccountFlow = false
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
// Set currentAccount to null immediately to prevent UI
|
// Set currentAccount to null immediately to prevent UI
|
||||||
// lag
|
// lag
|
||||||
currentAccount = null
|
currentAccount = null
|
||||||
@@ -316,8 +326,27 @@ class MainActivity : FragmentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
"main" -> {
|
"main" -> {
|
||||||
|
val activeAccountKey = currentAccount?.publicKey.orEmpty()
|
||||||
MainScreen(
|
MainScreen(
|
||||||
account = currentAccount,
|
account = currentAccount,
|
||||||
|
initialNavStack =
|
||||||
|
if (
|
||||||
|
activeAccountKey.isNotBlank() &&
|
||||||
|
preservedMainNavAccountKey.equals(
|
||||||
|
activeAccountKey,
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
preservedMainNavStack
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
},
|
||||||
|
onNavStackChanged = { stack ->
|
||||||
|
if (activeAccountKey.isNotBlank()) {
|
||||||
|
preservedMainNavAccountKey = activeAccountKey
|
||||||
|
preservedMainNavStack = stack
|
||||||
|
}
|
||||||
|
},
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
themeMode = themeMode,
|
themeMode = themeMode,
|
||||||
onToggleTheme = {
|
onToggleTheme = {
|
||||||
@@ -331,6 +360,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
startCreateAccountFlow = false
|
startCreateAccountFlow = false
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
// Set currentAccount to null immediately to prevent UI
|
// Set currentAccount to null immediately to prevent UI
|
||||||
// lag
|
// lag
|
||||||
currentAccount = null
|
currentAccount = null
|
||||||
@@ -343,6 +374,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
},
|
},
|
||||||
onDeleteAccount = {
|
onDeleteAccount = {
|
||||||
startCreateAccountFlow = false
|
startCreateAccountFlow = false
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
val publicKey = currentAccount?.publicKey ?: return@MainScreen
|
val publicKey = currentAccount?.publicKey ?: return@MainScreen
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -405,6 +438,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
hasExistingAccount = accounts.isNotEmpty()
|
hasExistingAccount = accounts.isNotEmpty()
|
||||||
// 9. If current account is deleted, return to main login screen
|
// 9. If current account is deleted, return to main login screen
|
||||||
if (currentAccount?.publicKey == targetPublicKey) {
|
if (currentAccount?.publicKey == targetPublicKey) {
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
currentAccount = null
|
currentAccount = null
|
||||||
clearCachedSessionAccount()
|
clearCachedSessionAccount()
|
||||||
}
|
}
|
||||||
@@ -415,6 +450,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
},
|
},
|
||||||
onSwitchAccount = { targetPublicKey ->
|
onSwitchAccount = { targetPublicKey ->
|
||||||
startCreateAccountFlow = false
|
startCreateAccountFlow = false
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
// Save target account before leaving main screen so Unlock
|
// Save target account before leaving main screen so Unlock
|
||||||
// screen preselects the account the user tapped.
|
// screen preselects the account the user tapped.
|
||||||
accountManager.setLastLoggedPublicKey(targetPublicKey)
|
accountManager.setLastLoggedPublicKey(targetPublicKey)
|
||||||
@@ -429,6 +466,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
},
|
},
|
||||||
onAddAccount = {
|
onAddAccount = {
|
||||||
startCreateAccountFlow = true
|
startCreateAccountFlow = true
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
currentAccount = null
|
currentAccount = null
|
||||||
clearCachedSessionAccount()
|
clearCachedSessionAccount()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -442,6 +481,8 @@ class MainActivity : FragmentActivity() {
|
|||||||
DeviceConfirmScreen(
|
DeviceConfirmScreen(
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
onExit = {
|
onExit = {
|
||||||
|
preservedMainNavStack = emptyList()
|
||||||
|
preservedMainNavAccountKey = ""
|
||||||
currentAccount = null
|
currentAccount = null
|
||||||
clearCachedSessionAccount()
|
clearCachedSessionAccount()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -614,6 +655,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
|
|||||||
sealed class Screen {
|
sealed class Screen {
|
||||||
data object Profile : Screen()
|
data object Profile : Screen()
|
||||||
data object ProfileFromChat : Screen()
|
data object ProfileFromChat : Screen()
|
||||||
|
data object Notifications : Screen()
|
||||||
data object Requests : Screen()
|
data object Requests : Screen()
|
||||||
data object Search : Screen()
|
data object Search : Screen()
|
||||||
data object GroupSetup : Screen()
|
data object GroupSetup : Screen()
|
||||||
@@ -634,6 +676,8 @@ sealed class Screen {
|
|||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
account: DecryptedAccount? = null,
|
account: DecryptedAccount? = null,
|
||||||
|
initialNavStack: List<Screen> = emptyList(),
|
||||||
|
onNavStackChanged: (List<Screen>) -> Unit = {},
|
||||||
isDarkTheme: Boolean = true,
|
isDarkTheme: Boolean = true,
|
||||||
themeMode: String = "dark",
|
themeMode: String = "dark",
|
||||||
onToggleTheme: () -> Unit = {},
|
onToggleTheme: () -> Unit = {},
|
||||||
@@ -941,7 +985,8 @@ fun MainScreen(
|
|||||||
// navigation change. This eliminates the massive recomposition
|
// navigation change. This eliminates the massive recomposition
|
||||||
// that happened when ANY boolean flag changed.
|
// that happened when ANY boolean flag changed.
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
var navStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
|
var navStack by remember(accountPublicKey) { mutableStateOf(initialNavStack) }
|
||||||
|
LaunchedEffect(navStack) { onNavStackChanged(navStack) }
|
||||||
|
|
||||||
// Derived visibility — only triggers recomposition when THIS screen changes
|
// Derived visibility — only triggers recomposition when THIS screen changes
|
||||||
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
|
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
|
||||||
@@ -949,6 +994,9 @@ fun MainScreen(
|
|||||||
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
|
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
|
||||||
}
|
}
|
||||||
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
|
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
|
||||||
|
val isNotificationsVisible by remember {
|
||||||
|
derivedStateOf { navStack.any { it is Screen.Notifications } }
|
||||||
|
}
|
||||||
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
|
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
|
||||||
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
|
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
|
||||||
val chatDetailScreen by remember {
|
val chatDetailScreen by remember {
|
||||||
@@ -980,6 +1028,9 @@ fun MainScreen(
|
|||||||
val isAppearanceVisible by remember {
|
val isAppearanceVisible by remember {
|
||||||
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
derivedStateOf { navStack.any { it is Screen.Appearance } }
|
||||||
}
|
}
|
||||||
|
var profileHasUnsavedChanges by remember(accountPublicKey) { mutableStateOf(false) }
|
||||||
|
var showDiscardProfileChangesDialog by remember { mutableStateOf(false) }
|
||||||
|
var discardProfileChangesFromChat by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Navigation helpers
|
// Navigation helpers
|
||||||
fun pushScreen(screen: Screen) {
|
fun pushScreen(screen: Screen) {
|
||||||
@@ -1028,6 +1079,7 @@ fun MainScreen(
|
|||||||
navStack.filterNot {
|
navStack.filterNot {
|
||||||
it is Screen.Profile ||
|
it is Screen.Profile ||
|
||||||
it is Screen.Theme ||
|
it is Screen.Theme ||
|
||||||
|
it is Screen.Notifications ||
|
||||||
it is Screen.Safety ||
|
it is Screen.Safety ||
|
||||||
it is Screen.Backup ||
|
it is Screen.Backup ||
|
||||||
it is Screen.Logs ||
|
it is Screen.Logs ||
|
||||||
@@ -1036,6 +1088,21 @@ fun MainScreen(
|
|||||||
it is Screen.Appearance
|
it is Screen.Appearance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun performProfileBack(fromChat: Boolean) {
|
||||||
|
if (fromChat) {
|
||||||
|
navStack = navStack.filterNot { it is Screen.ProfileFromChat }
|
||||||
|
} else {
|
||||||
|
popProfileAndChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun requestProfileBack(fromChat: Boolean) {
|
||||||
|
if (profileHasUnsavedChanges) {
|
||||||
|
discardProfileChangesFromChat = fromChat
|
||||||
|
showDiscardProfileChangesDialog = true
|
||||||
|
} else {
|
||||||
|
performProfileBack(fromChat)
|
||||||
|
}
|
||||||
|
}
|
||||||
fun popChatAndChildren() {
|
fun popChatAndChildren() {
|
||||||
navStack =
|
navStack =
|
||||||
navStack.filterNot {
|
navStack.filterNot {
|
||||||
@@ -1043,6 +1110,13 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isProfileVisible, isProfileFromChatVisible) {
|
||||||
|
if (!isProfileVisible && !isProfileFromChatVisible) {
|
||||||
|
profileHasUnsavedChanges = false
|
||||||
|
showDiscardProfileChangesDialog = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ProfileViewModel для логов
|
// ProfileViewModel для логов
|
||||||
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
|
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
|
||||||
androidx.lifecycle.viewmodel.compose.viewModel()
|
androidx.lifecycle.viewmodel.compose.viewModel()
|
||||||
@@ -1196,9 +1270,10 @@ fun MainScreen(
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = isProfileVisible,
|
isVisible = isProfileVisible,
|
||||||
onBack = { popProfileAndChildren() },
|
onBack = { requestProfileBack(fromChat = false) },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 1,
|
layer = 1,
|
||||||
|
swipeEnabled = !profileHasUnsavedChanges,
|
||||||
propagateBackgroundProgress = false
|
propagateBackgroundProgress = false
|
||||||
) {
|
) {
|
||||||
// Экран профиля
|
// Экран профиля
|
||||||
@@ -1209,13 +1284,15 @@ fun MainScreen(
|
|||||||
accountVerified = accountVerified,
|
accountVerified = accountVerified,
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
accountPrivateKeyHash = privateKeyHash,
|
accountPrivateKeyHash = privateKeyHash,
|
||||||
onBack = { popProfileAndChildren() },
|
onBack = { requestProfileBack(fromChat = false) },
|
||||||
|
onHasChangesChanged = { profileHasUnsavedChanges = it },
|
||||||
onSaveProfile = { name, username ->
|
onSaveProfile = { name, username ->
|
||||||
accountName = name
|
accountName = name
|
||||||
accountUsername = username
|
accountUsername = username
|
||||||
mainScreenScope.launch { onAccountInfoUpdated() }
|
mainScreenScope.launch { onAccountInfoUpdated() }
|
||||||
},
|
},
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
|
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
|
||||||
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
||||||
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
||||||
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
||||||
@@ -1229,6 +1306,18 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Other screens with swipe back
|
// Other screens with swipe back
|
||||||
|
SwipeBackContainer(
|
||||||
|
isVisible = isNotificationsVisible,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } },
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
layer = 2
|
||||||
|
) {
|
||||||
|
NotificationsScreen(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
onBack = { navStack = navStack.filterNot { it is Screen.Notifications } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = isSafetyVisible,
|
isVisible = isSafetyVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
|
||||||
@@ -1420,9 +1509,10 @@ fun MainScreen(
|
|||||||
|
|
||||||
SwipeBackContainer(
|
SwipeBackContainer(
|
||||||
isVisible = isProfileFromChatVisible,
|
isVisible = isProfileFromChatVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
|
onBack = { requestProfileBack(fromChat = true) },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 1,
|
layer = 1,
|
||||||
|
swipeEnabled = !profileHasUnsavedChanges,
|
||||||
propagateBackgroundProgress = false
|
propagateBackgroundProgress = false
|
||||||
) {
|
) {
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
@@ -1432,13 +1522,15 @@ fun MainScreen(
|
|||||||
accountVerified = accountVerified,
|
accountVerified = accountVerified,
|
||||||
accountPublicKey = accountPublicKey,
|
accountPublicKey = accountPublicKey,
|
||||||
accountPrivateKeyHash = privateKeyHash,
|
accountPrivateKeyHash = privateKeyHash,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
|
onBack = { requestProfileBack(fromChat = true) },
|
||||||
|
onHasChangesChanged = { profileHasUnsavedChanges = it },
|
||||||
onSaveProfile = { name, username ->
|
onSaveProfile = { name, username ->
|
||||||
accountName = name
|
accountName = name
|
||||||
accountUsername = username
|
accountUsername = username
|
||||||
mainScreenScope.launch { onAccountInfoUpdated() }
|
mainScreenScope.launch { onAccountInfoUpdated() }
|
||||||
},
|
},
|
||||||
onLogout = onLogout,
|
onLogout = onLogout,
|
||||||
|
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
|
||||||
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
||||||
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
||||||
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
||||||
@@ -1498,8 +1590,7 @@ fun MainScreen(
|
|||||||
isVisible = isSearchVisible,
|
isVisible = isSearchVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 1,
|
layer = 1
|
||||||
deferToChildren = true
|
|
||||||
) {
|
) {
|
||||||
// Экран поиска
|
// Экран поиска
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
@@ -1690,6 +1781,21 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCallScreenVisible) {
|
||||||
|
// Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay.
|
||||||
|
// Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.).
|
||||||
|
val blockerInteraction = remember { MutableInteractionSource() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clickable(
|
||||||
|
interactionSource = blockerInteraction,
|
||||||
|
indication = null,
|
||||||
|
onClick = {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
CallOverlay(
|
CallOverlay(
|
||||||
state = callUiState,
|
state = callUiState,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
@@ -1706,5 +1812,43 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showDiscardProfileChangesDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDiscardProfileChangesDialog = false },
|
||||||
|
containerColor = if (isDarkTheme) Color(0xFF1E1E20) else Color.White,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Discard changes?",
|
||||||
|
color = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = "You have unsaved profile changes. If you leave now, they will be lost.",
|
||||||
|
color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
showDiscardProfileChangesDialog = false
|
||||||
|
profileHasUnsavedChanges = false
|
||||||
|
performProfileBack(discardProfileChangesFromChat)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Discard", color = Color(0xFFFF3B30))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDiscardProfileChangesDialog = false }) {
|
||||||
|
Text(
|
||||||
|
"Stay",
|
||||||
|
color = if (isDarkTheme) Color(0xFF5FA8FF) else Color(0xFF0D8CF4)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,6 +448,18 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
return if (raw < 1_000_000_000_000L) raw * 1000L else raw
|
return if (raw < 1_000_000_000_000L) raw * 1000L else raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize incoming message timestamp for chat ordering:
|
||||||
|
* 1) accept both seconds and milliseconds;
|
||||||
|
* 2) never allow a message timestamp from the future on this device.
|
||||||
|
*/
|
||||||
|
private fun normalizeIncomingPacketTimestamp(rawTimestamp: Long, receivedAtMs: Long): Long {
|
||||||
|
val normalizedRaw =
|
||||||
|
if (rawTimestamp in 1..999_999_999_999L) rawTimestamp * 1000L else rawTimestamp
|
||||||
|
if (normalizedRaw <= 0L) return receivedAtMs
|
||||||
|
return minOf(normalizedRaw, receivedAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
/** Получить поток сообщений для диалога */
|
/** Получить поток сообщений для диалога */
|
||||||
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
|
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
|
||||||
val dialogKey = getDialogKey(opponentKey)
|
val dialogKey = getDialogKey(opponentKey)
|
||||||
@@ -711,6 +723,13 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val isOwnMessage = packet.fromPublicKey == account
|
val isOwnMessage = packet.fromPublicKey == account
|
||||||
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
|
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
|
||||||
|
val normalizedPacketTimestamp =
|
||||||
|
normalizeIncomingPacketTimestamp(packet.timestamp, startTime)
|
||||||
|
if (normalizedPacketTimestamp != packet.timestamp) {
|
||||||
|
MessageLogger.debug(
|
||||||
|
"📥 TIMESTAMP normalized: raw=${packet.timestamp} -> local=$normalizedPacketTimestamp"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||||
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
|
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
|
||||||
@@ -911,7 +930,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
fromPublicKey = packet.fromPublicKey,
|
fromPublicKey = packet.fromPublicKey,
|
||||||
toPublicKey = packet.toPublicKey,
|
toPublicKey = packet.toPublicKey,
|
||||||
content = packet.content,
|
content = packet.content,
|
||||||
timestamp = packet.timestamp,
|
timestamp = normalizedPacketTimestamp,
|
||||||
chachaKey = storedChachaKey,
|
chachaKey = storedChachaKey,
|
||||||
read = 0,
|
read = 0,
|
||||||
fromMe = if (isOwnMessage) 1 else 0,
|
fromMe = if (isOwnMessage) 1 else 0,
|
||||||
|
|||||||
@@ -17,20 +17,19 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Звонки и lockscreen
|
Синхронизация (как на Desktop)
|
||||||
- MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства
|
- Во время sync экран чатов показывает "Updating..." и скрывает шумящие промежуточные индикаторы
|
||||||
- Во входящем полноэкранном звонке отключено автоматическое снятие keyguard
|
- На период синхронизации скрываются badge'ы непрочитанного и requests, чтобы список не "прыгал"
|
||||||
- Исправлено краткое появление "Unknown" при завершении полноэкранного звонка
|
|
||||||
- При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT
|
|
||||||
|
|
||||||
Сеть и протокол
|
Медиа и вложения
|
||||||
- Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback)
|
- Исправлен кейс, когда фото уже отправлено, но локально оставалось в ERROR с красным индикатором
|
||||||
- Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета
|
- Для исходящих медиа стабилизирован переход статусов: после успешной отправки фиксируется SENT без ложного timeout->ERROR
|
||||||
- Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware
|
- Таймаут/ретрай WAITING из БД больше не портит медиа-вложения (применяется только к обычным текстовым ожиданиям)
|
||||||
|
- Для legacy/неподдерживаемых attachment добавлен desktop-style fallback:
|
||||||
|
"This attachment is no longer available because it was sent for a previous version of the app."
|
||||||
|
|
||||||
Стабильность UI
|
Группы и UI
|
||||||
- Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов)
|
- Исправлена геометрия входящих фото в группах: пузырь больше не прилипает к аватарке
|
||||||
- SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account AND dialog_key = :dialogKey
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
ORDER BY timestamp DESC, message_id DESC
|
ORDER BY timestamp DESC, id DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -260,7 +260,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND from_public_key = :account
|
AND from_public_key = :account
|
||||||
AND to_public_key = :account
|
AND to_public_key = :account
|
||||||
ORDER BY timestamp DESC, message_id DESC
|
ORDER BY timestamp DESC, id DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -286,7 +286,7 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account AND dialog_key = :dialogKey
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
ORDER BY timestamp ASC, message_id ASC
|
ORDER BY timestamp ASC, id ASC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
|
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
|
||||||
@@ -319,7 +319,7 @@ interface MessageDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account AND dialog_key = :dialogKey
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
ORDER BY timestamp DESC, message_id DESC
|
ORDER BY timestamp DESC, id DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -378,7 +378,7 @@ interface MessageDao {
|
|||||||
AND dialog_key = :dialogKey
|
AND dialog_key = :dialogKey
|
||||||
AND from_public_key = :fromPublicKey
|
AND from_public_key = :fromPublicKey
|
||||||
AND timestamp BETWEEN :timestampFrom AND :timestampTo
|
AND timestamp BETWEEN :timestampFrom AND :timestampTo
|
||||||
ORDER BY timestamp ASC, message_id ASC
|
ORDER BY timestamp ASC, id ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -462,7 +462,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||||
OR (from_public_key = :account AND to_public_key = :opponent))
|
OR (from_public_key = :account AND to_public_key = :opponent))
|
||||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
|
suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
|
||||||
@@ -477,7 +477,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||||
OR (from_public_key = :account AND to_public_key = :opponent))
|
OR (from_public_key = :account AND to_public_key = :opponent))
|
||||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
|
suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
|
||||||
@@ -492,7 +492,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||||
OR (from_public_key = :account AND to_public_key = :opponent))
|
OR (from_public_key = :account AND to_public_key = :opponent))
|
||||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getLastMessageAttachments(account: String, opponent: String): String?
|
suspend fun getLastMessageAttachments(account: String, opponent: String): String?
|
||||||
@@ -508,6 +508,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND from_me = 1
|
AND from_me = 1
|
||||||
AND delivered = 0
|
AND delivered = 0
|
||||||
|
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
|
||||||
AND timestamp >= :minTimestamp
|
AND timestamp >= :minTimestamp
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC
|
||||||
"""
|
"""
|
||||||
@@ -524,6 +525,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND from_me = 1
|
AND from_me = 1
|
||||||
AND delivered = 0
|
AND delivered = 0
|
||||||
|
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
|
||||||
AND timestamp < :maxTimestamp
|
AND timestamp < :maxTimestamp
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -629,7 +631,7 @@ interface MessageDao {
|
|||||||
END
|
END
|
||||||
WHERE m.account = :account
|
WHERE m.account = :account
|
||||||
AND m.primary_attachment_type = 4
|
AND m.primary_attachment_type = 4
|
||||||
ORDER BY m.timestamp DESC, m.message_id DESC
|
ORDER BY m.timestamp DESC, m.id DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -978,7 +980,7 @@ interface DialogDao {
|
|||||||
"""
|
"""
|
||||||
SELECT * FROM messages
|
SELECT * FROM messages
|
||||||
WHERE account = :account AND dialog_key = :dialogKey
|
WHERE account = :account AND dialog_key = :dialogKey
|
||||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?
|
suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
|||||||
THEN dialogs.account || ':' || dialogs.opponent_key
|
THEN dialogs.account || ':' || dialogs.opponent_key
|
||||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||||
END
|
END
|
||||||
ORDER BY m.timestamp DESC, m.message_id DESC
|
ORDER BY m.timestamp DESC, m.id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
),
|
),
|
||||||
''
|
''
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import java.util.concurrent.ConcurrentHashMap
|
|||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
import kotlin.coroutines.suspendCoroutine
|
|
||||||
import kotlin.coroutines.coroutineContext
|
import kotlin.coroutines.coroutineContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +43,7 @@ object TransportManager {
|
|||||||
|
|
||||||
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
|
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||||
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
|
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
|
||||||
|
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
|
||||||
|
|
||||||
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||||
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||||
@@ -133,6 +133,14 @@ object TransportManager {
|
|||||||
_downloading.value = _downloading.value.filter { it.id != id }
|
_downloading.value = _downloading.value.filter { it.id != id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принудительно отменяет активный HTTP call для upload attachment.
|
||||||
|
*/
|
||||||
|
fun cancelUpload(id: String) {
|
||||||
|
activeUploadCalls.remove(id)?.cancel()
|
||||||
|
_uploading.value = _uploading.value.filter { it.id != id }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
|
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
|
||||||
suspendCancellableCoroutine { cont ->
|
suspendCancellableCoroutine { cont ->
|
||||||
val call = client.newCall(request)
|
val call = client.newCall(request)
|
||||||
@@ -224,13 +232,31 @@ object TransportManager {
|
|||||||
.post(requestBody)
|
.post(requestBody)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val response = suspendCoroutine<Response> { cont ->
|
val response = suspendCancellableCoroutine<Response> { cont ->
|
||||||
client.newCall(request).enqueue(object : Callback {
|
val call = client.newCall(request)
|
||||||
|
activeUploadCalls[id] = call
|
||||||
|
|
||||||
|
cont.invokeOnCancellation {
|
||||||
|
activeUploadCalls.remove(id, call)
|
||||||
|
call.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
call.enqueue(object : Callback {
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
activeUploadCalls.remove(id, call)
|
||||||
|
if (call.isCanceled()) {
|
||||||
|
cont.cancel(CancellationException("Upload cancelled"))
|
||||||
|
} else {
|
||||||
cont.resumeWithException(e)
|
cont.resumeWithException(e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
activeUploadCalls.remove(id, call)
|
||||||
|
if (cont.isCancelled) {
|
||||||
|
response.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
cont.resume(response)
|
cont.resume(response)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -253,12 +279,16 @@ object TransportManager {
|
|||||||
|
|
||||||
tag
|
tag
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
ProtocolManager.addLog(
|
ProtocolManager.addLog(
|
||||||
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
|
activeUploadCalls.remove(id)?.cancel()
|
||||||
// Удаляем из списка загрузок
|
// Удаляем из списка загрузок
|
||||||
_uploading.value = _uploading.value.filter { it.id != id }
|
_uploading.value = _uploading.value.filter { it.id != id }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.compose.runtime.*
|
|||||||
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
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -35,19 +34,18 @@ fun SeedPhraseScreen(
|
|||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
|
|
||||||
|
|
||||||
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
var isGenerating by remember { mutableStateOf(true) }
|
|
||||||
var hasCopied by remember { mutableStateOf(false) }
|
var hasCopied by remember { mutableStateOf(false) }
|
||||||
var visible by remember { mutableStateOf(false) }
|
var visible by remember { mutableStateOf(false) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(100)
|
// Генерируем фразу сразу, без задержек
|
||||||
seedPhrase = CryptoManager.generateSeedPhrase()
|
seedPhrase = CryptoManager.generateSeedPhrase()
|
||||||
isGenerating = false
|
// Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию
|
||||||
|
delay(50)
|
||||||
visible = true
|
visible = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +57,7 @@ fun SeedPhraseScreen(
|
|||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Simple top bar
|
// Top bar
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -108,30 +106,13 @@ fun SeedPhraseScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
// Two column layout
|
// Сетка со словами (без Crossfade и лоадера)
|
||||||
if (isGenerating) {
|
if (seedPhrase.isNotEmpty()) {
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(300.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color = PrimaryBlue,
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
modifier = Modifier.size(40.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = fadeIn(tween(500, delayMillis = 200))
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
// Left column (words 1-6)
|
// Левая колонка (1-6)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
@@ -142,12 +123,12 @@ fun SeedPhraseScreen(
|
|||||||
word = seedPhrase[i],
|
word = seedPhrase[i],
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
visible = visible,
|
visible = visible,
|
||||||
delay = 300 + (i * 50)
|
delay = i * 60
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right column (words 7-12)
|
// Правая колонка (7-12)
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
@@ -158,23 +139,21 @@ fun SeedPhraseScreen(
|
|||||||
word = seedPhrase[i],
|
word = seedPhrase[i],
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
visible = visible,
|
visible = visible,
|
||||||
delay = 300 + (i * 50)
|
delay = i * 60
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Copy button
|
// Кнопка Copy
|
||||||
if (!isGenerating) {
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn(
|
enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn(
|
||||||
initialScale = 0.8f,
|
initialScale = 0.8f,
|
||||||
animationSpec = tween(500, delayMillis = 600)
|
animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -201,33 +180,32 @@ fun SeedPhraseScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
// Continue button
|
// Кнопка Continue
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = 700))
|
enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically(
|
||||||
|
initialOffsetY = { 20 },
|
||||||
|
animationSpec = tween(400, delayMillis = 900)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onConfirm(seedPhrase) },
|
onClick = { onConfirm(seedPhrase) },
|
||||||
enabled = !isGenerating,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(50.dp),
|
.height(52.dp),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = PrimaryBlue,
|
containerColor = PrimaryBlue,
|
||||||
contentColor = Color.White,
|
contentColor = Color.White
|
||||||
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
|
|
||||||
disabledContentColor = Color.White.copy(alpha = 0.5f)
|
|
||||||
),
|
),
|
||||||
shape = RoundedCornerShape(12.dp)
|
shape = RoundedCornerShape(14.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Continue",
|
text = "Continue",
|
||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
fontWeight = FontWeight.Medium
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,21 +224,11 @@ private fun WordItem(
|
|||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
|
val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999)
|
||||||
|
|
||||||
// Beautiful solid colors that fit the theme
|
|
||||||
val wordColors = listOf(
|
val wordColors = listOf(
|
||||||
Color(0xFF5E9FFF), // Soft blue
|
Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE),
|
||||||
Color(0xFFFF7EB3), // Soft pink
|
Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4),
|
||||||
Color(0xFF7B68EE), // Medium purple
|
Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB),
|
||||||
Color(0xFF50C878), // Emerald green
|
Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F)
|
||||||
Color(0xFFFF6B6B), // Coral red
|
|
||||||
Color(0xFF4ECDC4), // Teal
|
|
||||||
Color(0xFFFFB347), // Pastel orange
|
|
||||||
Color(0xFFBA55D3), // Medium orchid
|
|
||||||
Color(0xFF87CEEB), // Sky blue
|
|
||||||
Color(0xFFDDA0DD), // Plum
|
|
||||||
Color(0xFF98D8C8), // Mint
|
|
||||||
Color(0xFFF7DC6F) // Soft yellow
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val wordColor = wordColors[(number - 1) % wordColors.size]
|
val wordColor = wordColors[(number - 1) % wordColors.size]
|
||||||
@@ -271,21 +239,18 @@ private fun WordItem(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
.background(bgColor)
|
.background(bgColor)
|
||||||
.padding(horizontal = 16.dp, vertical = 14.dp)
|
.padding(horizontal = 14.dp, vertical = 12.dp)
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text = "$number.",
|
text = "$number.",
|
||||||
fontSize = 15.sp,
|
fontSize = 13.sp,
|
||||||
color = numberColor,
|
color = numberColor,
|
||||||
modifier = Modifier.width(28.dp)
|
modifier = Modifier.width(26.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = word,
|
text = word,
|
||||||
fontSize = 17.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = wordColor,
|
color = wordColor,
|
||||||
fontFamily = FontFamily.Monospace
|
fontFamily = FontFamily.Monospace
|
||||||
@@ -303,15 +268,21 @@ private fun AnimatedWordItem(
|
|||||||
delay: Int,
|
delay: Int,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
|
val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) }
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = fadeIn(tween(400, delayMillis = delay))
|
enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) +
|
||||||
) {
|
slideInVertically(
|
||||||
WordItem(
|
initialOffsetY = { 30 },
|
||||||
number = number,
|
animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing)
|
||||||
word = word,
|
) +
|
||||||
isDarkTheme = isDarkTheme,
|
scaleIn(
|
||||||
|
initialScale = 0.85f,
|
||||||
|
animationSpec = tween(400, delayMillis = delay, easing = overshootEasing)
|
||||||
|
),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
) {
|
||||||
|
WordItem(number, word, isDarkTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ fun SelectAccountScreen(
|
|||||||
DisposableEffect(isDarkTheme) {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
onDispose { }
|
onDispose { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3306,6 +3306,14 @@ fun ChatDetailScreen(
|
|||||||
message.id
|
message.id
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onCancelPhotoUpload = {
|
||||||
|
attachmentId ->
|
||||||
|
viewModel
|
||||||
|
.cancelOutgoingImageUpload(
|
||||||
|
message.id,
|
||||||
|
attachmentId
|
||||||
|
)
|
||||||
|
},
|
||||||
onImageClick = {
|
onImageClick = {
|
||||||
attachmentId,
|
attachmentId,
|
||||||
bounds
|
bounds
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||||
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
|
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
|
||||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val chatMessageAscComparator =
|
// Keep sort stable for equal timestamps: tie order comes from existing list/insertion order.
|
||||||
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
|
||||||
private val chatMessageDescComparator =
|
private val chatMessageDescComparator =
|
||||||
compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id }
|
compareByDescending<ChatMessage> { it.timestamp.time }
|
||||||
|
|
||||||
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
|
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
|
||||||
messages.sortedWith(chatMessageAscComparator)
|
messages.sortedWith(chatMessageAscComparator)
|
||||||
@@ -227,6 +227,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Job для отмены загрузки при смене диалога
|
// Job для отмены загрузки при смене диалога
|
||||||
private var loadingJob: Job? = null
|
private var loadingJob: Job? = null
|
||||||
private var draftSaveJob: Job? = null
|
private var draftSaveJob: Job? = null
|
||||||
|
private val outgoingImageUploadJobs = ConcurrentHashMap<String, Job>()
|
||||||
|
|
||||||
// 🔥 Throttling для typing индикатора
|
// 🔥 Throttling для typing индикатора
|
||||||
private var lastTypingSentTime = 0L
|
private var lastTypingSentTime = 0L
|
||||||
@@ -619,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
updateCacheFromCurrentMessages()
|
updateCacheFromCurrentMessages()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки:
|
||||||
|
* optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен.
|
||||||
|
*/
|
||||||
|
private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean {
|
||||||
|
if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false
|
||||||
|
|
||||||
|
val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false
|
||||||
|
if (attachments.length() == 0) return false
|
||||||
|
|
||||||
|
var hasMediaAttachment = false
|
||||||
|
for (index in 0 until attachments.length()) {
|
||||||
|
val attachment = attachments.optJSONObject(index) ?: continue
|
||||||
|
when (parseAttachmentType(attachment)) {
|
||||||
|
AttachmentType.IMAGE,
|
||||||
|
AttachmentType.FILE,
|
||||||
|
AttachmentType.AVATAR -> {
|
||||||
|
hasMediaAttachment = true
|
||||||
|
if (attachment.optString("localUri", "").isNotBlank()) {
|
||||||
|
// Локальный URI ещё есть => загрузка/подготовка не завершена.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttachmentType.UNKNOWN -> continue
|
||||||
|
else -> return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasMediaAttachment
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus {
|
||||||
|
return when (entity.delivered) {
|
||||||
|
DeliveryStatus.WAITING.value ->
|
||||||
|
if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT
|
||||||
|
else MessageStatus.SENDING
|
||||||
|
DeliveryStatus.DELIVERED.value ->
|
||||||
|
if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
||||||
|
DeliveryStatus.ERROR.value -> MessageStatus.ERROR
|
||||||
|
DeliveryStatus.READ.value -> MessageStatus.READ
|
||||||
|
else -> MessageStatus.SENT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun shortPhotoId(value: String, limit: Int = 8): String {
|
private fun shortPhotoId(value: String, limit: Int = 8): String {
|
||||||
val trimmed = value.trim()
|
val trimmed = value.trim()
|
||||||
if (trimmed.isEmpty()) return "unknown"
|
if (trimmed.isEmpty()) return "unknown"
|
||||||
@@ -1044,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
currentMessages.map { message ->
|
currentMessages.map { message ->
|
||||||
val entity = entitiesById[message.id] ?: return@map message
|
val entity = entitiesById[message.id] ?: return@map message
|
||||||
|
|
||||||
val dbStatus =
|
val dbStatus = mapEntityDeliveryStatus(entity)
|
||||||
when (entity.delivered) {
|
|
||||||
0 -> MessageStatus.SENDING
|
|
||||||
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
|
||||||
2 -> MessageStatus.ERROR
|
|
||||||
3 -> MessageStatus.READ
|
|
||||||
else -> MessageStatus.SENT
|
|
||||||
}
|
|
||||||
|
|
||||||
var updatedMessage = message
|
var updatedMessage = message
|
||||||
if (updatedMessage.status != dbStatus) {
|
if (updatedMessage.status != dbStatus) {
|
||||||
@@ -1370,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
text = displayText,
|
text = displayText,
|
||||||
isOutgoing = entity.fromMe == 1,
|
isOutgoing = entity.fromMe == 1,
|
||||||
timestamp = Date(entity.timestamp),
|
timestamp = Date(entity.timestamp),
|
||||||
status =
|
status = mapEntityDeliveryStatus(entity),
|
||||||
when (entity.delivered) {
|
|
||||||
0 -> MessageStatus.SENDING
|
|
||||||
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
|
|
||||||
2 -> MessageStatus.ERROR
|
|
||||||
3 -> MessageStatus.READ
|
|
||||||
else -> MessageStatus.SENT
|
|
||||||
},
|
|
||||||
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
replyData = if (forwardedMessages.isNotEmpty()) null else replyData,
|
||||||
forwardedMessages = forwardedMessages,
|
forwardedMessages = forwardedMessages,
|
||||||
attachments = finalAttachments,
|
attachments = finalAttachments,
|
||||||
@@ -2579,6 +2610,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 🛑 Отменить исходящую отправку фото во время загрузки */
|
||||||
|
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) {
|
||||||
|
ProtocolManager.addLog(
|
||||||
|
"🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}"
|
||||||
|
)
|
||||||
|
outgoingImageUploadJobs.remove(messageId)?.cancel(
|
||||||
|
CancellationException("User cancelled image upload")
|
||||||
|
)
|
||||||
|
TransportManager.cancelUpload(attachmentId)
|
||||||
|
ProtocolManager.resolveOutgoingRetry(messageId)
|
||||||
|
deleteMessage(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
/** 🔥 Удалить сообщение (для ошибки отправки) */
|
||||||
fun deleteMessage(messageId: String) {
|
fun deleteMessage(messageId: String) {
|
||||||
val account = myPublicKey ?: return
|
val account = myPublicKey ?: return
|
||||||
@@ -3514,7 +3558,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
|
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
|
||||||
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
|
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
|
||||||
backgroundUploadScope.launch {
|
val uploadJob = backgroundUploadScope.launch {
|
||||||
try {
|
try {
|
||||||
logPhotoPipeline(messageId, "persist optimistic message in DB")
|
logPhotoPipeline(messageId, "persist optimistic message in DB")
|
||||||
val optimisticAttachmentsJson =
|
val optimisticAttachmentsJson =
|
||||||
@@ -3554,6 +3598,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
logPhotoPipeline(messageId, "optimistic dialog updated")
|
logPhotoPipeline(messageId, "optimistic dialog updated")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
|
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
|
||||||
}
|
}
|
||||||
@@ -3603,6 +3649,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
privateKey = privateKey
|
privateKey = privateKey
|
||||||
)
|
)
|
||||||
logPhotoPipeline(messageId, "pipeline completed")
|
logPhotoPipeline(messageId, "pipeline completed")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
logPhotoPipeline(messageId, "pipeline cancelled by user")
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logPhotoPipelineError(messageId, "prepare+convert", e)
|
logPhotoPipelineError(messageId, "prepare+convert", e)
|
||||||
if (!isCleared) {
|
if (!isCleared) {
|
||||||
@@ -3612,6 +3661,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
outgoingImageUploadJobs[messageId] = uploadJob
|
||||||
|
uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
|
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
|
||||||
@@ -3655,6 +3706,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
sender: String,
|
sender: String,
|
||||||
privateKey: String
|
privateKey: String
|
||||||
) {
|
) {
|
||||||
|
var packetSentToProtocol = false
|
||||||
try {
|
try {
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
val pipelineStartedAt = System.currentTimeMillis()
|
val pipelineStartedAt = System.currentTimeMillis()
|
||||||
@@ -3746,6 +3798,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Отправляем пакет
|
// Отправляем пакет
|
||||||
if (!isSavedMessages) {
|
if (!isSavedMessages) {
|
||||||
ProtocolManager.send(packet)
|
ProtocolManager.send(packet)
|
||||||
|
packetSentToProtocol = true
|
||||||
logPhotoPipeline(messageId, "packet sent to protocol")
|
logPhotoPipeline(messageId, "packet sent to protocol")
|
||||||
} else {
|
} else {
|
||||||
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
|
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
|
||||||
@@ -3790,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
logPhotoPipeline(messageId, "db status+attachments updated")
|
logPhotoPipeline(messageId, "db status+attachments updated")
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (isSavedMessages) {
|
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
}
|
|
||||||
// Также очищаем localUri в UI
|
// Также очищаем localUri в UI
|
||||||
updateMessageAttachments(messageId, null)
|
updateMessageAttachments(messageId, null)
|
||||||
}
|
}
|
||||||
logPhotoPipeline(
|
logPhotoPipeline(messageId, "ui status switched to SENT")
|
||||||
messageId,
|
|
||||||
if (isSavedMessages) "ui status switched to SENT"
|
|
||||||
else "ui status kept at SENDING until delivery ACK"
|
|
||||||
)
|
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
lastMessage = if (caption.isNotEmpty()) caption else "photo",
|
||||||
@@ -3811,11 +3858,22 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
messageId,
|
messageId,
|
||||||
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
|
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
|
||||||
)
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
logPhotoPipeline(messageId, "internal-send cancelled")
|
||||||
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logPhotoPipelineError(messageId, "internal-send", e)
|
logPhotoPipelineError(messageId, "internal-send", e)
|
||||||
|
if (packetSentToProtocol) {
|
||||||
|
// Packet already sent to server: local post-send failure (cache/DB/UI) must not mark message as failed.
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
|
}
|
||||||
|
logPhotoPipeline(messageId, "post-send non-fatal error: status kept as SENT")
|
||||||
|
} else {
|
||||||
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📸 Отправка сообщения с изображением
|
* 📸 Отправка сообщения с изображением
|
||||||
@@ -3989,12 +4047,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId).
|
// После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR).
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (isSavedMessages) {
|
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (text.isNotEmpty()) text else "photo",
|
lastMessage = if (text.isNotEmpty()) text else "photo",
|
||||||
@@ -4277,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
_messages.value.map { msg ->
|
_messages.value.map { msg ->
|
||||||
if (msg.id != messageId) return@map msg
|
if (msg.id != messageId) return@map msg
|
||||||
msg.copy(
|
msg.copy(
|
||||||
status =
|
status = MessageStatus.SENT,
|
||||||
if (isSavedMessages) MessageStatus.SENT
|
|
||||||
else MessageStatus.SENDING,
|
|
||||||
attachments =
|
attachments =
|
||||||
msg.attachments.map { current ->
|
msg.attachments.map { current ->
|
||||||
val final = finalAttachmentsById[current.id]
|
val final = finalAttachmentsById[current.id]
|
||||||
@@ -4510,12 +4564,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
opponentPublicKey = recipient
|
opponentPublicKey = recipient
|
||||||
)
|
)
|
||||||
|
|
||||||
// Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId).
|
// После успешной отправки медиа переводим в SENT.
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (isSavedMessages) {
|
|
||||||
updateMessageStatus(messageId, MessageStatus.SENT)
|
updateMessageStatus(messageId, MessageStatus.SENT)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
saveDialog(
|
saveDialog(
|
||||||
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
|
lastMessage = if (text.isNotEmpty()) text else "📷 ${images.size} photos",
|
||||||
@@ -5469,6 +5521,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
typingNameResolveJob?.cancel()
|
typingNameResolveJob?.cancel()
|
||||||
draftSaveJob?.cancel()
|
draftSaveJob?.cancel()
|
||||||
pinnedCollectionJob?.cancel()
|
pinnedCollectionJob?.cancel()
|
||||||
|
outgoingImageUploadJobs.values.forEach { it.cancel() }
|
||||||
|
outgoingImageUploadJobs.clear()
|
||||||
|
|
||||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||||
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||||
|
|||||||
@@ -610,6 +610,7 @@ fun ChatsListScreen(
|
|||||||
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
val topLevelChatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
|
val topLevelIsLoading by chatsViewModel.isLoading.collectAsState()
|
||||||
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
val topLevelRequestsCount = topLevelChatsState.requestsCount
|
||||||
|
val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount
|
||||||
|
|
||||||
// Dev console dialog - commented out for now
|
// Dev console dialog - commented out for now
|
||||||
/*
|
/*
|
||||||
@@ -1163,7 +1164,7 @@ fun ChatsListScreen(
|
|||||||
text = "Requests",
|
text = "Requests",
|
||||||
iconColor = menuIconColor,
|
iconColor = menuIconColor,
|
||||||
textColor = menuTextColor,
|
textColor = menuTextColor,
|
||||||
badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null,
|
badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null,
|
||||||
badgeColor = accentColor,
|
badgeColor = accentColor,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -1598,7 +1599,7 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
// Badge с числом запросов
|
// Badge с числом запросов
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
visible = topLevelRequestsCount > 0,
|
visible = visibleTopLevelRequestsCount > 0,
|
||||||
enter = scaleIn(
|
enter = scaleIn(
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
@@ -1608,10 +1609,10 @@ fun ChatsListScreen(
|
|||||||
exit = scaleOut() + fadeOut(),
|
exit = scaleOut() + fadeOut(),
|
||||||
modifier = Modifier.align(Alignment.TopEnd)
|
modifier = Modifier.align(Alignment.TopEnd)
|
||||||
) {
|
) {
|
||||||
val badgeText = remember(topLevelRequestsCount) {
|
val badgeText = remember(visibleTopLevelRequestsCount) {
|
||||||
when {
|
when {
|
||||||
topLevelRequestsCount > 99 -> "99+"
|
visibleTopLevelRequestsCount > 99 -> "99+"
|
||||||
else -> topLevelRequestsCount.toString()
|
else -> visibleTopLevelRequestsCount.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val badgeBg = Color.White
|
val badgeBg = Color.White
|
||||||
@@ -1679,7 +1680,7 @@ fun ChatsListScreen(
|
|||||||
)
|
)
|
||||||
} else if (syncInProgress) {
|
} else if (syncInProgress) {
|
||||||
AnimatedDotsText(
|
AnimatedDotsText(
|
||||||
baseText = "Synchronizing",
|
baseText = "Updating",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
@@ -1858,8 +1859,8 @@ fun ChatsListScreen(
|
|||||||
// независимо
|
// независимо
|
||||||
val chatsState = topLevelChatsState
|
val chatsState = topLevelChatsState
|
||||||
val isLoading = topLevelIsLoading
|
val isLoading = topLevelIsLoading
|
||||||
val requests = chatsState.requests
|
val requests = if (syncInProgress) emptyList() else chatsState.requests
|
||||||
val requestsCount = chatsState.requestsCount
|
val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount
|
||||||
|
|
||||||
val showSkeleton by
|
val showSkeleton by
|
||||||
produceState(
|
produceState(
|
||||||
@@ -3464,7 +3465,8 @@ fun ChatItem(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
showOnlineIndicator = true,
|
showOnlineIndicator = true,
|
||||||
isOnline = chat.isOnline,
|
isOnline = chat.isOnline,
|
||||||
displayName = chat.name // 🔥 Для инициалов
|
displayName = chat.name, // 🔥 Для инициалов
|
||||||
|
enableBlurPrewarm = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
|
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
|
||||||
@@ -4117,6 +4119,7 @@ fun DialogItemContent(
|
|||||||
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
|
||||||
val secondaryTextColor =
|
val secondaryTextColor =
|
||||||
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) }
|
||||||
|
val visibleUnreadCount = if (syncInProgress) 0 else dialog.unreadCount
|
||||||
|
|
||||||
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) }
|
||||||
|
|
||||||
@@ -4208,7 +4211,8 @@ fun DialogItemContent(
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
size = TELEGRAM_DIALOG_AVATAR_SIZE,
|
size = TELEGRAM_DIALOG_AVATAR_SIZE,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
displayName = avatarDisplayName
|
displayName = avatarDisplayName,
|
||||||
|
enableBlurPrewarm = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4448,7 +4452,7 @@ fun DialogItemContent(
|
|||||||
text = formattedTime,
|
text = formattedTime,
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color =
|
color =
|
||||||
if (dialog.unreadCount > 0) PrimaryBlue
|
if (visibleUnreadCount > 0) PrimaryBlue
|
||||||
else secondaryTextColor
|
else secondaryTextColor
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4588,7 +4592,7 @@ fun DialogItemContent(
|
|||||||
baseDisplayText,
|
baseDisplayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color =
|
color =
|
||||||
if (dialog.unreadCount >
|
if (visibleUnreadCount >
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
textColor.copy(
|
textColor.copy(
|
||||||
@@ -4598,7 +4602,7 @@ fun DialogItemContent(
|
|||||||
else
|
else
|
||||||
secondaryTextColor,
|
secondaryTextColor,
|
||||||
fontWeight =
|
fontWeight =
|
||||||
if (dialog.unreadCount >
|
if (visibleUnreadCount >
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
FontWeight.Medium
|
FontWeight.Medium
|
||||||
@@ -4619,11 +4623,11 @@ fun DialogItemContent(
|
|||||||
text = baseDisplayText,
|
text = baseDisplayText,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color =
|
color =
|
||||||
if (dialog.unreadCount > 0)
|
if (visibleUnreadCount > 0)
|
||||||
textColor.copy(alpha = 0.85f)
|
textColor.copy(alpha = 0.85f)
|
||||||
else secondaryTextColor,
|
else secondaryTextColor,
|
||||||
fontWeight =
|
fontWeight =
|
||||||
if (dialog.unreadCount > 0)
|
if (visibleUnreadCount > 0)
|
||||||
FontWeight.Medium
|
FontWeight.Medium
|
||||||
else FontWeight.Normal,
|
else FontWeight.Normal,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -4658,15 +4662,15 @@ fun DialogItemContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unread badge
|
// Unread badge
|
||||||
if (dialog.unreadCount > 0) {
|
if (visibleUnreadCount > 0) {
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
val unreadText =
|
val unreadText =
|
||||||
remember(dialog.unreadCount) {
|
remember(visibleUnreadCount) {
|
||||||
when {
|
when {
|
||||||
dialog.unreadCount > 999 -> "999+"
|
visibleUnreadCount > 999 -> "999+"
|
||||||
dialog.unreadCount > 99 -> "99+"
|
visibleUnreadCount > 99 -> "99+"
|
||||||
else ->
|
else ->
|
||||||
dialog.unreadCount
|
visibleUnreadCount
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4736,7 +4740,7 @@ fun TypingIndicatorSmall(
|
|||||||
typingDisplayName: String = "",
|
typingDisplayName: String = "",
|
||||||
typingSenderPublicKey: String = ""
|
typingSenderPublicKey: String = ""
|
||||||
) {
|
) {
|
||||||
val typingColor = PrimaryBlue
|
val typingColor = if (isDarkTheme) PrimaryBlue else Color(0xFF34C759)
|
||||||
val senderTypingColor =
|
val senderTypingColor =
|
||||||
remember(typingSenderPublicKey, isDarkTheme) {
|
remember(typingSenderPublicKey, isDarkTheme) {
|
||||||
if (typingSenderPublicKey.isBlank()) {
|
if (typingSenderPublicKey.isBlank()) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager
|
|||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
@@ -391,13 +392,22 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Подписываемся на обычные диалоги
|
// Подписываемся на обычные диалоги
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getDialogsFlow(publicKey)
|
.getDialogsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||||
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
||||||
.map { dialogsList ->
|
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
|
||||||
|
dialogsList to syncing
|
||||||
|
}
|
||||||
|
.mapLatest { (dialogsList, syncing) ->
|
||||||
|
// Desktop behavior parity:
|
||||||
|
// while sync is active we keep current chats list stable (no per-message UI churn),
|
||||||
|
// then apply one consolidated update when sync finishes.
|
||||||
|
if (syncing && _dialogs.value.isNotEmpty()) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
mapDialogListIncremental(
|
mapDialogListIncremental(
|
||||||
dialogsList = dialogsList,
|
dialogsList = dialogsList,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
@@ -405,6 +415,8 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
isRequestsFlow = false
|
isRequestsFlow = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.filterNotNull()
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedDialogs ->
|
.collect { decryptedDialogs ->
|
||||||
_dialogs.value = decryptedDialogs
|
_dialogs.value = decryptedDialogs
|
||||||
@@ -423,13 +435,19 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||||
launch {
|
launch {
|
||||||
dialogDao
|
dialogDao
|
||||||
.getRequestsFlow(publicKey)
|
.getRequestsFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
.debounce(100) // 🚀 Батчим быстрые обновления
|
.debounce(100) // 🚀 Батчим быстрые обновления
|
||||||
.map { requestsList ->
|
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
|
||||||
|
requestsList to syncing
|
||||||
|
}
|
||||||
|
.mapLatest { (requestsList, syncing) ->
|
||||||
|
if (syncing) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
mapDialogListIncremental(
|
mapDialogListIncremental(
|
||||||
dialogsList = requestsList,
|
dialogsList = requestsList,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
@@ -437,6 +455,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
isRequestsFlow = true
|
isRequestsFlow = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||||
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
||||||
}
|
}
|
||||||
@@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
|||||||
dialogDao
|
dialogDao
|
||||||
.getRequestsCountFlow(publicKey)
|
.getRequestsCountFlow(publicKey)
|
||||||
.flowOn(Dispatchers.IO)
|
.flowOn(Dispatchers.IO)
|
||||||
|
.combine(ProtocolManager.syncInProgress) { count, syncing ->
|
||||||
|
if (syncing) 0 else count
|
||||||
|
}
|
||||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
||||||
.collect { count -> _requestsCount.value = count }
|
.collect { count -> _requestsCount.value = count }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ fun GroupSetupScreen(
|
|||||||
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
|
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val secondaryTextColor = Color(0xFF8E8E93)
|
val secondaryTextColor = Color(0xFF8E8E93)
|
||||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||||
|
val disabledActionColor = if (isDarkTheme) Color(0xFF5A5A5E) else Color(0xFFC7C7CC)
|
||||||
|
val disabledActionContentColor = if (isDarkTheme) Color(0xFFAAAAAF) else Color(0xFF8E8E93)
|
||||||
val groupAvatarCameraButtonColor =
|
val groupAvatarCameraButtonColor =
|
||||||
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
|
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
|
||||||
val groupAvatarCameraIconColor =
|
val groupAvatarCameraIconColor =
|
||||||
@@ -745,8 +747,8 @@ fun GroupSetupScreen(
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
|
containerColor = if (actionEnabled) accentColor else disabledActionColor,
|
||||||
contentColor = Color.White,
|
contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
modifier = run {
|
modifier = run {
|
||||||
// Берём максимум из всех позиций — при переключении keyboard↔emoji
|
// Берём максимум из всех позиций — при переключении keyboard↔emoji
|
||||||
@@ -762,7 +764,7 @@ fun GroupSetupScreen(
|
|||||||
) {
|
) {
|
||||||
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
|
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
color = Color.White,
|
color = if (actionEnabled) Color.White else disabledActionContentColor,
|
||||||
strokeWidth = 2.dp,
|
strokeWidth = 2.dp,
|
||||||
modifier = Modifier.size(22.dp)
|
modifier = Modifier.size(22.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
@@ -39,12 +40,12 @@ fun RequestsListScreen(
|
|||||||
avatarRepository: AvatarRepository? = null
|
avatarRepository: AvatarRepository? = null
|
||||||
) {
|
) {
|
||||||
val chatsState by chatsViewModel.chatsState.collectAsState()
|
val chatsState by chatsViewModel.chatsState.collectAsState()
|
||||||
val requests = chatsState.requests
|
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||||
|
val requests = if (syncInProgress) emptyList() else chatsState.requests
|
||||||
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||||
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -60,7 +61,7 @@ fun RequestsListScreen(
|
|||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "Requests",
|
text = if (syncInProgress) "Updating..." else "Requests",
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
color = Color.White
|
color = Color.White
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
@@ -109,7 +110,7 @@ fun SearchScreen(
|
|||||||
DisposableEffect(isDarkTheme) {
|
DisposableEffect(isDarkTheme) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
onDispose {
|
onDispose {
|
||||||
// Restore white status bar icons for chat list header
|
// Restore white status bar icons for chat list header
|
||||||
@@ -238,14 +239,10 @@ fun SearchScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
detectHorizontalDragGestures { _, dragAmount ->
|
val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) }
|
||||||
if (dragAmount > 10f) {
|
|
||||||
hideKeyboardInstantly()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(hideKbScrollConnection),
|
||||||
topBar = {
|
topBar = {
|
||||||
// Хедер как в Telegram: стрелка назад + поле ввода
|
// Хедер как в Telegram: стрелка назад + поле ввода
|
||||||
Surface(
|
Surface(
|
||||||
@@ -538,7 +535,7 @@ private fun ChatsTabContent(
|
|||||||
if (searchQuery.isEmpty()) {
|
if (searchQuery.isEmpty()) {
|
||||||
// ═══ Idle state: frequent contacts + recent searches ═══
|
// ═══ Idle state: frequent contacts + recent searches ═══
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize().imePadding()
|
||||||
) {
|
) {
|
||||||
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
|
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
|
||||||
if (visibleFrequentContacts.isNotEmpty()) {
|
if (visibleFrequentContacts.isNotEmpty()) {
|
||||||
@@ -1183,7 +1180,7 @@ private fun MessagesTabContent(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp)
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
items(results, key = { it.messageId }) { result ->
|
items(results, key = { it.messageId }) { result ->
|
||||||
@@ -1757,7 +1754,7 @@ private fun DownloadsTabContent(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp)
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
items(files, key = { it.name }) { downloadedFile ->
|
items(files, key = { it.name }) { downloadedFile ->
|
||||||
@@ -1909,7 +1906,7 @@ private fun FilesTabContent(
|
|||||||
} else {
|
} else {
|
||||||
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
|
val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize().imePadding(),
|
||||||
contentPadding = PaddingValues(vertical = 4.dp)
|
contentPadding = PaddingValues(vertical = 4.dp)
|
||||||
) {
|
) {
|
||||||
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
|
items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item ->
|
||||||
@@ -2163,3 +2160,21 @@ private fun RecentUserItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** NestedScrollConnection который скрывает клавиатуру при любом вертикальном скролле */
|
||||||
|
private class HideKeyboardNestedScroll(
|
||||||
|
private val view: android.view.View,
|
||||||
|
private val focusManager: androidx.compose.ui.focus.FocusManager
|
||||||
|
) : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {
|
||||||
|
override fun onPreScroll(
|
||||||
|
available: androidx.compose.ui.geometry.Offset,
|
||||||
|
source: androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
): androidx.compose.ui.geometry.Offset {
|
||||||
|
if (kotlin.math.abs(available.y) > 0.5f) {
|
||||||
|
val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
focusManager.clearFocus()
|
||||||
|
}
|
||||||
|
return androidx.compose.ui.geometry.Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ fun ChatAttachAlert(
|
|||||||
// as the popup overlay, so top area and content overlay always match.
|
// as the popup overlay, so top area and content overlay always match.
|
||||||
if (fullScreen) {
|
if (fullScreen) {
|
||||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
insetsController?.isAppearanceLightStatusBars = !dark
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
} else {
|
} else {
|
||||||
insetsController?.isAppearanceLightStatusBars = false
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
var lastAppliedAlpha = -1
|
var lastAppliedAlpha = -1
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import kotlin.math.min
|
|||||||
private const val TAG = "AttachmentComponents"
|
private const val TAG = "AttachmentComponents"
|
||||||
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
|
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
|
||||||
private val whitespaceRegex = "\\s+".toRegex()
|
private val whitespaceRegex = "\\s+".toRegex()
|
||||||
|
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }
|
||||||
|
|
||||||
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||||
|
|
||||||
@@ -144,6 +145,42 @@ private fun shortDebugHash(bytes: ByteArray): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val LEGACY_ATTACHMENT_ERROR_TEXT =
|
||||||
|
"This attachment is no longer available because it was sent for a previous version of the app."
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LegacyAttachmentErrorCard(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF)
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC)
|
||||||
|
val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
modifier.fillMaxWidth()
|
||||||
|
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(backgroundColor)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFE55A5A),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = LEGACY_ATTACHMENT_ERROR_TEXT,
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Анимированный текст с волнообразными точками.
|
* Анимированный текст с волнообразными точками.
|
||||||
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||||
@@ -463,6 +500,7 @@ fun MessageAttachments(
|
|||||||
showTail: Boolean = true, // Показывать хвостик пузырька
|
showTail: Boolean = true, // Показывать хвостик пузырька
|
||||||
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
|
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
|
||||||
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
|
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
|
||||||
|
onCancelUpload: (attachmentId: String) -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
@@ -478,6 +516,7 @@ fun MessageAttachments(
|
|||||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
// 🖼️ Коллаж для изображений (если больше 1)
|
// 🖼️ Коллаж для изображений (если больше 1)
|
||||||
if (imageAttachments.isNotEmpty()) {
|
if (imageAttachments.isNotEmpty()) {
|
||||||
|
CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) {
|
||||||
ImageCollage(
|
ImageCollage(
|
||||||
attachments = imageAttachments,
|
attachments = imageAttachments,
|
||||||
chachaKey = chachaKey,
|
chachaKey = chachaKey,
|
||||||
@@ -494,6 +533,7 @@ fun MessageAttachments(
|
|||||||
onImageClick = onImageClick
|
onImageClick = onImageClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Остальные attachments по отдельности
|
// Остальные attachments по отдельности
|
||||||
otherAttachments.forEach { attachment ->
|
otherAttachments.forEach { attachment ->
|
||||||
@@ -534,7 +574,8 @@ fun MessageAttachments(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
/* MESSAGES обрабатываются отдельно */
|
// Desktop parity: unsupported/legacy attachment gets explicit compatibility card.
|
||||||
|
LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -926,6 +967,11 @@ fun ImageAttachment(
|
|||||||
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
|
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
|
||||||
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
|
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
|
||||||
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
|
var errorLabel by remember(attachment.id) { mutableStateOf("Error") }
|
||||||
|
val uploadingState by TransportManager.uploading.collectAsState()
|
||||||
|
val uploadEntry = uploadingState.firstOrNull { it.id == attachment.id }
|
||||||
|
val uploadProgress = uploadEntry?.progress ?: 0
|
||||||
|
val isUploadInProgress = uploadEntry != null
|
||||||
|
val onCancelImageUpload = LocalOnCancelImageUpload.current
|
||||||
|
|
||||||
val preview = getPreview(attachment)
|
val preview = getPreview(attachment)
|
||||||
val downloadTag = getDownloadTag(attachment)
|
val downloadTag = getDownloadTag(attachment)
|
||||||
@@ -1470,26 +1516,75 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок)
|
// Desktop-style upload state: Encrypting... (0%) / progress ring (>0%)
|
||||||
// Показываем когда сообщение отправляется И это исходящее сообщение
|
if (isOutgoing &&
|
||||||
if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) {
|
messageStatus == MessageStatus.SENDING &&
|
||||||
|
downloadStatus == DownloadStatus.DOWNLOADED &&
|
||||||
|
(isUploadInProgress || attachment.localUri.isNotEmpty())
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
|
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
// Круглый индикатор отправки
|
if (uploadProgress > 0) {
|
||||||
|
val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(44.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(Color.Black.copy(alpha = 0.5f)),
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
enabled = !isSelectionMode
|
||||||
|
) {
|
||||||
|
onCancelImageUpload(attachment.id)
|
||||||
|
},
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier.size(28.dp),
|
progress = cappedProgress,
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp,
|
||||||
|
trackColor = Color.White.copy(alpha = 0.25f),
|
||||||
|
strokeCap = StrokeCap.Round
|
||||||
)
|
)
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Cancel upload",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.Black.copy(alpha = 0.5f))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
enabled = !isSelectionMode
|
||||||
|
) {
|
||||||
|
onCancelImageUpload(attachment.id)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
AnimatedDotsText(
|
||||||
|
baseText = "Encrypting",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,8 @@ fun TypingIndicator(
|
|||||||
typingDisplayName: String = "",
|
typingDisplayName: String = "",
|
||||||
typingSenderPublicKey: String = ""
|
typingSenderPublicKey: String = ""
|
||||||
) {
|
) {
|
||||||
val typingColor = Color(0xFF54A9EB)
|
val typingColor =
|
||||||
|
if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
|
||||||
val senderTypingColor =
|
val senderTypingColor =
|
||||||
remember(typingSenderPublicKey, isDarkTheme) {
|
remember(typingSenderPublicKey, isDarkTheme) {
|
||||||
if (typingSenderPublicKey.isBlank()) {
|
if (typingSenderPublicKey.isBlank()) {
|
||||||
@@ -345,6 +346,7 @@ fun MessageBubble(
|
|||||||
onReplyClick: (String) -> Unit = {},
|
onReplyClick: (String) -> Unit = {},
|
||||||
onRetry: () -> Unit = {},
|
onRetry: () -> Unit = {},
|
||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
|
onCancelPhotoUpload: (attachmentId: String) -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
onAvatarClick: (senderPublicKey: String) -> Unit = {},
|
onAvatarClick: (senderPublicKey: String) -> Unit = {},
|
||||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||||
@@ -571,6 +573,7 @@ fun MessageBubble(
|
|||||||
val telegramIncomingAvatarSize = 42.dp
|
val telegramIncomingAvatarSize = 42.dp
|
||||||
val telegramIncomingAvatarLane = 48.dp
|
val telegramIncomingAvatarLane = 48.dp
|
||||||
val telegramIncomingAvatarInset = 6.dp
|
val telegramIncomingAvatarInset = 6.dp
|
||||||
|
val telegramIncomingBubbleGap = 6.dp
|
||||||
val shouldShowIncomingGroupAvatar =
|
val shouldShowIncomingGroupAvatar =
|
||||||
showIncomingGroupAvatar
|
showIncomingGroupAvatar
|
||||||
?: (isGroupChat &&
|
?: (isGroupChat &&
|
||||||
@@ -811,7 +814,13 @@ fun MessageBubble(
|
|||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.align(Alignment.Bottom)
|
Modifier.align(Alignment.Bottom)
|
||||||
.padding(end = 12.dp)
|
.padding(
|
||||||
|
start =
|
||||||
|
if (!message.isOutgoing && isGroupChat)
|
||||||
|
telegramIncomingBubbleGap
|
||||||
|
else 0.dp,
|
||||||
|
end = 12.dp
|
||||||
|
)
|
||||||
.then(bubbleWidthModifier)
|
.then(bubbleWidthModifier)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
this.alpha = selectionAlpha
|
this.alpha = selectionAlpha
|
||||||
@@ -984,6 +993,7 @@ fun MessageBubble(
|
|||||||
// пузырька
|
// пузырька
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
onCancelUpload = onCancelPhotoUpload,
|
||||||
// В selection mode блокируем открытие фото
|
// В selection mode блокируем открытие фото
|
||||||
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
|
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -631,7 +631,7 @@ fun MediaPickerBottomSheet(
|
|||||||
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
|
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
|
||||||
if (fullScreen) {
|
if (fullScreen) {
|
||||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||||
insetsController?.isAppearanceLightStatusBars = !dark
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
} else {
|
} else {
|
||||||
insetsController?.isAppearanceLightStatusBars = false
|
insetsController?.isAppearanceLightStatusBars = false
|
||||||
var lastAppliedAlpha = -1
|
var lastAppliedAlpha = -1
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -76,8 +77,10 @@ fun AvatarImage(
|
|||||||
showOnlineIndicator: Boolean = false,
|
showOnlineIndicator: Boolean = false,
|
||||||
isOnline: Boolean = false,
|
isOnline: Boolean = false,
|
||||||
shape: Shape = CircleShape,
|
shape: Shape = CircleShape,
|
||||||
displayName: String? = null // 🔥 Имя для инициалов (title/username)
|
displayName: String? = null, // 🔥 Имя для инициалов (title/username)
|
||||||
|
enableBlurPrewarm: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
|
||||||
val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY
|
val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY
|
||||||
|
|
||||||
@@ -118,6 +121,18 @@ fun AvatarImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(enableBlurPrewarm, publicKey, avatarKey, bitmap) {
|
||||||
|
if (!enableBlurPrewarm) return@LaunchedEffect
|
||||||
|
val source = bitmap ?: return@LaunchedEffect
|
||||||
|
prewarmBlurredAvatarFromBitmap(
|
||||||
|
context = context,
|
||||||
|
publicKey = publicKey,
|
||||||
|
avatarTimestamp = avatarKey,
|
||||||
|
sourceBitmap = source,
|
||||||
|
blurRadius = 20f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
|
import android.util.LruCache
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.renderscript.Allocation
|
import android.renderscript.Allocation
|
||||||
import android.renderscript.Element
|
import android.renderscript.Element
|
||||||
@@ -38,6 +39,51 @@ import com.rosetta.messenger.utils.AvatarFileManager
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
|
private object BlurredAvatarMemoryCache {
|
||||||
|
private val cache = object : LruCache<String, Bitmap>(18 * 1024) {
|
||||||
|
override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
private val latestByPublicKey = object : LruCache<String, Bitmap>(12 * 1024) {
|
||||||
|
override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(key: String): Bitmap? = cache.get(key)
|
||||||
|
fun getLatest(publicKey: String): Bitmap? = latestByPublicKey.get(publicKey)
|
||||||
|
fun put(key: String, bitmap: Bitmap) {
|
||||||
|
if (bitmap.isRecycled) return
|
||||||
|
cache.put(key, bitmap)
|
||||||
|
val publicKey = key.substringBeforeLast(':', "")
|
||||||
|
if (publicKey.isNotEmpty()) {
|
||||||
|
latestByPublicKey.put(publicKey, bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val blurPrewarmInFlight: MutableSet<String> = Collections.synchronizedSet(mutableSetOf())
|
||||||
|
|
||||||
|
internal suspend fun prewarmBlurredAvatarFromBitmap(
|
||||||
|
context: Context,
|
||||||
|
publicKey: String,
|
||||||
|
avatarTimestamp: Long,
|
||||||
|
sourceBitmap: Bitmap,
|
||||||
|
blurRadius: Float = 20f
|
||||||
|
) {
|
||||||
|
if (sourceBitmap.isRecycled || publicKey.isBlank()) return
|
||||||
|
val cacheKey = "$publicKey:$avatarTimestamp"
|
||||||
|
if (BlurredAvatarMemoryCache.get(cacheKey) != null) return
|
||||||
|
if (!blurPrewarmInFlight.add(cacheKey)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
val blurred = withContext(Dispatchers.Default) {
|
||||||
|
gaussianBlur(context, sourceBitmap, radius = blurRadius, passes = 2)
|
||||||
|
}
|
||||||
|
BlurredAvatarMemoryCache.put(cacheKey, blurred)
|
||||||
|
} finally {
|
||||||
|
blurPrewarmInFlight.remove(cacheKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Компонент для отображения размытого фона аватарки
|
* Компонент для отображения размытого фона аватарки
|
||||||
@@ -55,6 +101,7 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
isDarkTheme: Boolean = true
|
isDarkTheme: Boolean = true
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val hasColorOverlay = overlayColors != null && overlayColors.isNotEmpty()
|
||||||
|
|
||||||
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
||||||
?: remember { mutableStateOf(emptyList()) }
|
?: remember { mutableStateOf(emptyList()) }
|
||||||
@@ -62,11 +109,24 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
val avatarKey = remember(avatars) {
|
val avatarKey = remember(avatars) {
|
||||||
avatars.firstOrNull()?.timestamp ?: 0L
|
avatars.firstOrNull()?.timestamp ?: 0L
|
||||||
}
|
}
|
||||||
|
val blurCacheKey = remember(publicKey, avatarKey) { "$publicKey:$avatarKey" }
|
||||||
|
|
||||||
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var originalBitmap by remember(publicKey) { mutableStateOf<Bitmap?>(null) }
|
||||||
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var blurredBitmap by remember(publicKey) {
|
||||||
|
mutableStateOf(BlurredAvatarMemoryCache.getLatest(publicKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(publicKey, avatarKey, blurCacheKey, hasColorOverlay) {
|
||||||
|
// For custom color/gradient background we intentionally disable avatar blur layer.
|
||||||
|
if (hasColorOverlay) {
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
BlurredAvatarMemoryCache.get(blurCacheKey)?.let { cached ->
|
||||||
|
blurredBitmap = cached
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(avatarKey, publicKey) {
|
|
||||||
val currentAvatars = avatars
|
val currentAvatars = avatars
|
||||||
val newOriginal = withContext(Dispatchers.IO) {
|
val newOriginal = withContext(Dispatchers.IO) {
|
||||||
// Keep system account blur source identical to the visible avatar drawable.
|
// Keep system account blur source identical to the visible avatar drawable.
|
||||||
@@ -81,46 +141,36 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
}
|
}
|
||||||
if (newOriginal != null) {
|
if (newOriginal != null) {
|
||||||
originalBitmap = newOriginal
|
originalBitmap = newOriginal
|
||||||
blurredBitmap = withContext(Dispatchers.Default) {
|
val blurred = withContext(Dispatchers.Default) {
|
||||||
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
|
gaussianBlur(context, newOriginal, radius = blurRadius, passes = 2)
|
||||||
}
|
}
|
||||||
} else {
|
blurredBitmap = blurred
|
||||||
|
BlurredAvatarMemoryCache.put(blurCacheKey, blurred)
|
||||||
|
} else if (blurredBitmap == null) {
|
||||||
originalBitmap = null
|
originalBitmap = null
|
||||||
blurredBitmap = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Режим с overlay-цветом — blur + пастельный overlay
|
// Режим с overlay-цветом — blur + пастельный overlay
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
if (hasColorOverlay) {
|
||||||
// LAYER 1: Blurred avatar (яркий, видный)
|
val overlayPalette = overlayColors.orEmpty()
|
||||||
if (blurredBitmap != null) {
|
// LAYER 1: Pure color/gradient background (no avatar blur behind avatar).
|
||||||
Image(
|
val overlayMod = if (overlayPalette.size == 1) {
|
||||||
bitmap = blurredBitmap!!.asImageBitmap(),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.matchParentSize()
|
|
||||||
.graphicsLayer { this.alpha = 0.85f },
|
|
||||||
contentScale = ContentScale.Crop
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LAYER 2: Цвет/градиент overlay
|
|
||||||
val colorAlpha = if (blurredBitmap != null) 0.4f else 0.85f
|
|
||||||
val overlayMod = if (overlayColors.size == 1) {
|
|
||||||
Modifier.matchParentSize()
|
Modifier.matchParentSize()
|
||||||
.background(overlayColors[0].copy(alpha = colorAlpha))
|
.background(overlayPalette[0])
|
||||||
} else {
|
} else {
|
||||||
Modifier.matchParentSize()
|
Modifier.matchParentSize()
|
||||||
.background(
|
.background(
|
||||||
Brush.linearGradient(
|
Brush.linearGradient(
|
||||||
colors = overlayColors.map { it.copy(alpha = colorAlpha) }
|
colors = overlayPalette
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(modifier = overlayMod)
|
Box(modifier = overlayMod)
|
||||||
|
|
||||||
// LAYER 3: Нижний градиент для читаемости текста
|
// LAYER 2: Нижний градиент для читаемости текста
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.matchParentSize()
|
.matchParentSize()
|
||||||
@@ -141,13 +191,14 @@ fun BoxScope.BlurredAvatarBackground(
|
|||||||
// Стандартный режим (нет overlay-цвета) — blur аватарки
|
// Стандартный режим (нет overlay-цвета) — blur аватарки
|
||||||
// Telegram-style: яркий видный блюр + мягкое затемнение
|
// Telegram-style: яркий видный блюр + мягкое затемнение
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
if (blurredBitmap != null) {
|
val displayBitmap = blurredBitmap ?: originalBitmap
|
||||||
|
if (displayBitmap != null) {
|
||||||
// LAYER 1: Размытая аватарка — яркая и видная
|
// LAYER 1: Размытая аватарка — яркая и видная
|
||||||
Image(
|
Image(
|
||||||
bitmap = blurredBitmap!!.asImageBitmap(),
|
bitmap = displayBitmap.asImageBitmap(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.matchParentSize()
|
modifier = Modifier.matchParentSize()
|
||||||
.graphicsLayer { this.alpha = 0.9f },
|
.graphicsLayer { this.alpha = if (blurredBitmap != null) 0.9f else 0.72f },
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
// LAYER 2: Лёгкое тонирование цветом аватара
|
// LAYER 2: Лёгкое тонирование цветом аватара
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ fun OnboardingScreen(
|
|||||||
if (shouldUpdateStatusBar && !view.isInEditMode) {
|
if (shouldUpdateStatusBar && !view.isInEditMode) {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
|
||||||
// Navigation bar: показываем только если есть нативные кнопки
|
// Navigation bar: показываем только если есть нативные кнопки
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ fun AppearanceScreen(
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -333,6 +333,7 @@ private fun ProfileBlurPreview(
|
|||||||
) {
|
) {
|
||||||
val option = BackgroundBlurPresets.findById(selectedId)
|
val option = BackgroundBlurPresets.findById(selectedId)
|
||||||
val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId)
|
val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId)
|
||||||
|
val shouldUseAvatarBlur = overlayColors.isNullOrEmpty() && selectedId != "none"
|
||||||
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
||||||
|
|
||||||
// Загрузка аватарки
|
// Загрузка аватарки
|
||||||
@@ -344,7 +345,7 @@ private fun ProfileBlurPreview(
|
|||||||
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
val blurContext = LocalContext.current
|
val blurContext = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(avatarKey) {
|
LaunchedEffect(avatarKey, shouldUseAvatarBlur) {
|
||||||
val current = avatars
|
val current = avatars
|
||||||
if (current.isNotEmpty()) {
|
if (current.isNotEmpty()) {
|
||||||
val decoded = withContext(Dispatchers.IO) {
|
val decoded = withContext(Dispatchers.IO) {
|
||||||
@@ -352,9 +353,14 @@ private fun ProfileBlurPreview(
|
|||||||
}
|
}
|
||||||
if (decoded != null) {
|
if (decoded != null) {
|
||||||
avatarBitmap = decoded
|
avatarBitmap = decoded
|
||||||
blurredBitmap = withContext(Dispatchers.Default) {
|
blurredBitmap =
|
||||||
|
if (shouldUseAvatarBlur) {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
|
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
avatarBitmap = null
|
avatarBitmap = null
|
||||||
@@ -375,9 +381,8 @@ private fun ProfileBlurPreview(
|
|||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
// LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground)
|
// LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground)
|
||||||
// ═══════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════
|
||||||
if (blurredBitmap != null) {
|
if (shouldUseAvatarBlur && blurredBitmap != null) {
|
||||||
// overlay-режим: 0.85f, стандартный: 0.9f (как в BlurredAvatarBackground)
|
val blurImgAlpha = 0.9f
|
||||||
val blurImgAlpha = if (overlayColors != null && overlayColors.isNotEmpty()) 0.85f else 0.9f
|
|
||||||
Image(
|
Image(
|
||||||
bitmap = blurredBitmap!!.asImageBitmap(),
|
bitmap = blurredBitmap!!.asImageBitmap(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -402,20 +407,18 @@ private fun ProfileBlurPreview(
|
|||||||
val overlayMod = if (overlayColors.size == 1) {
|
val overlayMod = if (overlayColors.size == 1) {
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f))
|
.background(overlayColors[0])
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(
|
.background(
|
||||||
Brush.linearGradient(
|
Brush.linearGradient(
|
||||||
colors = overlayColors.map {
|
colors = overlayColors
|
||||||
it.copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(modifier = overlayMod)
|
Box(modifier = overlayMod)
|
||||||
} else if (blurredBitmap != null) {
|
} else if (shouldUseAvatarBlur && blurredBitmap != null) {
|
||||||
// Стандартный тинт (идентичен BlurredAvatarBackground)
|
// Стандартный тинт (идентичен BlurredAvatarBackground)
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -782,4 +785,3 @@ private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Flo
|
|||||||
rs.destroy()
|
rs.destroy()
|
||||||
return Bitmap.createBitmap(current, pad, pad, w, h)
|
return Bitmap.createBitmap(current, pad, pad, w, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ fun BiometricEnableScreen(
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package com.rosetta.messenger.ui.settings
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rosetta.messenger.data.PreferencesManager
|
||||||
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.ChevronLeft
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationsScreen(
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val preferencesManager = remember { PreferencesManager(context) }
|
||||||
|
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
|
||||||
|
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||||
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
|
||||||
|
|
||||||
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
color = backgroundColor
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
|
||||||
|
.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = TablerIcons.ChevronLeft,
|
||||||
|
contentDescription = "Back",
|
||||||
|
tint = textColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Notifications",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = textColor,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
|
||||||
|
|
||||||
|
TelegramToggleItem(
|
||||||
|
icon = TelegramIcons.Notifications,
|
||||||
|
title = "Push Notifications",
|
||||||
|
subtitle = "Messages and call alerts",
|
||||||
|
isEnabled = notificationsEnabled,
|
||||||
|
onToggle = {
|
||||||
|
scope.launch {
|
||||||
|
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
showDivider = true
|
||||||
|
)
|
||||||
|
|
||||||
|
TelegramToggleItem(
|
||||||
|
icon = TelegramIcons.Photos,
|
||||||
|
title = "Avatars in Notifications",
|
||||||
|
subtitle = "Show sender avatar in push alerts",
|
||||||
|
isEnabled = avatarInNotifications,
|
||||||
|
onToggle = {
|
||||||
|
scope.launch {
|
||||||
|
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Changes apply instantly.",
|
||||||
|
color = secondaryTextColor,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@ fun ProfileLogsScreen(
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,8 +275,10 @@ fun ProfileScreen(
|
|||||||
accountPublicKey: String,
|
accountPublicKey: String,
|
||||||
accountPrivateKeyHash: String,
|
accountPrivateKeyHash: String,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onHasChangesChanged: (Boolean) -> Unit = {},
|
||||||
onSaveProfile: (name: String, username: String) -> Unit,
|
onSaveProfile: (name: String, username: String) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onNavigateToNotifications: () -> Unit = {},
|
||||||
onNavigateToTheme: () -> Unit = {},
|
onNavigateToTheme: () -> Unit = {},
|
||||||
onNavigateToAppearance: () -> Unit = {},
|
onNavigateToAppearance: () -> Unit = {},
|
||||||
onNavigateToSafety: () -> Unit = {},
|
onNavigateToSafety: () -> Unit = {},
|
||||||
@@ -402,7 +404,7 @@ fun ProfileScreen(
|
|||||||
// State for editing - Update when account data changes
|
// State for editing - Update when account data changes
|
||||||
var editedName by remember(accountName) { mutableStateOf(accountName) }
|
var editedName by remember(accountName) { mutableStateOf(accountName) }
|
||||||
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
|
var editedUsername by remember(accountUsername) { mutableStateOf(accountUsername) }
|
||||||
var hasChanges by remember { mutableStateOf(false) }
|
val hasChanges = editedName != accountName || editedUsername != accountUsername
|
||||||
var nameTouched by remember { mutableStateOf(false) }
|
var nameTouched by remember { mutableStateOf(false) }
|
||||||
var usernameTouched by remember { mutableStateOf(false) }
|
var usernameTouched by remember { mutableStateOf(false) }
|
||||||
var showValidationErrors by remember { mutableStateOf(false) }
|
var showValidationErrors by remember { mutableStateOf(false) }
|
||||||
@@ -701,7 +703,6 @@ fun ProfileScreen(
|
|||||||
// Following desktop version: update local data AFTER server confirms success
|
// Following desktop version: update local data AFTER server confirms success
|
||||||
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
|
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
|
||||||
|
|
||||||
hasChanges = false
|
|
||||||
serverNameError = null
|
serverNameError = null
|
||||||
serverUsernameError = null
|
serverUsernameError = null
|
||||||
serverGeneralError = null
|
serverGeneralError = null
|
||||||
@@ -743,9 +744,14 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update hasChanges when fields change
|
SideEffect {
|
||||||
LaunchedEffect(editedName, editedUsername) {
|
onHasChangesChanged(hasChanges)
|
||||||
hasChanges = editedName != accountName || editedUsername != accountUsername
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
onHasChangesChanged(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle back button press - navigate to chat list instead of closing app
|
// Handle back button press - navigate to chat list instead of closing app
|
||||||
@@ -838,49 +844,19 @@ fun ProfileScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
|
||||||
// 🔔 NOTIFICATIONS SECTION
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
|
||||||
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
|
|
||||||
|
|
||||||
run {
|
|
||||||
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
|
||||||
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
|
|
||||||
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
TelegramToggleItem(
|
|
||||||
icon = TelegramIcons.Notifications,
|
|
||||||
title = "Push Notifications",
|
|
||||||
isEnabled = notificationsEnabled,
|
|
||||||
onToggle = {
|
|
||||||
scope.launch {
|
|
||||||
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
|
|
||||||
TelegramToggleItem(
|
|
||||||
icon = TelegramIcons.Photos,
|
|
||||||
title = "Avatars in Notifications",
|
|
||||||
isEnabled = avatarInNotifications,
|
|
||||||
onToggle = {
|
|
||||||
scope.launch {
|
|
||||||
preferencesManager.setNotificationAvatarEnabled(!avatarInNotifications)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isDarkTheme = isDarkTheme
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
// ⚙️ SETTINGS SECTION - Telegram style
|
// ⚙️ SETTINGS SECTION - Telegram style
|
||||||
// ═════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════
|
||||||
TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme)
|
TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme)
|
||||||
|
|
||||||
|
TelegramSettingsItem(
|
||||||
|
icon = TelegramIcons.Notifications,
|
||||||
|
title = "Notifications",
|
||||||
|
onClick = onNavigateToNotifications,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
showDivider = true
|
||||||
|
)
|
||||||
|
|
||||||
TelegramSettingsItem(
|
TelegramSettingsItem(
|
||||||
icon = TelegramIcons.Theme,
|
icon = TelegramIcons.Theme,
|
||||||
title = "Theme",
|
title = "Theme",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ fun ThemeScreen(
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fun UpdatesScreen(
|
|||||||
SideEffect {
|
SideEffect {
|
||||||
val window = (view.context as android.app.Activity).window
|
val window = (view.context as android.app.Activity).window
|
||||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ object SystemBarsStyleUtils {
|
|||||||
if (window == null || view == null) return
|
if (window == null || view == null) return
|
||||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||||
window.statusBarColor = Color.TRANSPARENT
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
insetsController.isAppearanceLightStatusBars = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {
|
fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 2.0 MiB |
Reference in New Issue
Block a user