Доработки интерфейса и поведения настроек, профиля и групп

This commit is contained in:
2026-04-06 21:41:37 +05:00
parent 152106eda1
commit 081bdb6d30
26 changed files with 639 additions and 161 deletions

View File

@@ -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) {
@@ -1027,7 +1078,8 @@ fun MainScreen(
navStack = navStack =
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) },
@@ -1690,6 +1782,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 +1813,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)
)
}
}
)
}
} }
} }

View File

@@ -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,

View File

@@ -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?
@@ -629,7 +629,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 +978,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?

View File

@@ -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
), ),
'' ''

View File

@@ -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) {
cont.resumeWithException(e) activeUploadCalls.remove(id, call)
if (call.isCanceled()) {
cont.cancel(CancellationException("Upload cancelled"))
} else {
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 }
} }

View File

@@ -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 { }
} }

View File

@@ -3306,6 +3306,14 @@ fun ChatDetailScreen(
message.id message.id
) )
}, },
onCancelPhotoUpload = {
attachmentId ->
viewModel
.cancelOutgoingImageUpload(
message.id,
attachmentId
)
},
onImageClick = { onImageClick = {
attachmentId, attachmentId,
bounds bounds

View File

@@ -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
@@ -2579,6 +2580,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 +3528,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 +3568,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 +3619,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 +3631,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
} }
outgoingImageUploadJobs[messageId] = uploadJob
uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) }
} }
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */ /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
@@ -3655,6 +3676,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 +3768,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")
@@ -3811,9 +3834,20 @@ 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)
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) } 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) }
}
} }
} }
@@ -5469,6 +5503,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)

View File

@@ -3464,7 +3464,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))
@@ -4208,7 +4209,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
) )
} }
@@ -4736,7 +4738,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()) {

View File

@@ -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)
) )

View File

@@ -109,7 +109,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

View File

@@ -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

View File

@@ -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:")
@@ -463,6 +464,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,21 +480,23 @@ 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()) {
ImageCollage( CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) {
attachments = imageAttachments, ImageCollage(
chachaKey = chachaKey, attachments = imageAttachments,
privateKey = privateKey, chachaKey = chachaKey,
senderPublicKey = senderPublicKey, privateKey = privateKey,
isOutgoing = isOutgoing, senderPublicKey = senderPublicKey,
isDarkTheme = isDarkTheme, isOutgoing = isOutgoing,
timestamp = timestamp, isDarkTheme = isDarkTheme,
messageStatus = messageStatus, timestamp = timestamp,
hasCaption = hasCaption, messageStatus = messageStatus,
showTail = showTail, hasCaption = hasCaption,
isSelectionMode = isSelectionMode, showTail = showTail,
onLongClick = onLongClick, isSelectionMode = isSelectionMode,
onImageClick = onImageClick onLongClick = onLongClick,
) onImageClick = onImageClick
)
}
} }
// Остальные attachments по отдельности // Остальные attachments по отдельности
@@ -926,6 +930,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 +1479,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) {
Box( val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f
modifier = Modifier Box(
.size(48.dp) modifier = Modifier
.clip(CircleShape) .size(44.dp)
.background(Color.Black.copy(alpha = 0.5f)), .clip(CircleShape)
contentAlignment = Alignment.Center .background(Color.Black.copy(alpha = 0.5f))
) { .clickable(
CircularProgressIndicator( interactionSource = remember { MutableInteractionSource() },
modifier = Modifier.size(28.dp), indication = null,
color = Color.White, enabled = !isSelectionMode
strokeWidth = 2.5.dp ) {
) onCancelImageUpload(attachment.id)
},
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = cappedProgress,
modifier = Modifier.size(36.dp),
color = Color.White,
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
)
}
} }
} }
} }

View File

@@ -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 = {},
@@ -984,6 +986,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
) )

View File

@@ -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

View File

@@ -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)

View File

@@ -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: Лёгкое тонирование цветом аватара

View File

@@ -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: показываем только если есть нативные кнопки

View File

@@ -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 =
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2) if (shouldUseAvatarBlur) {
} withContext(Dispatchers.Default) {
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)
} }

View File

@@ -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
} }
} }

View File

@@ -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)
)
}
}
}

View File

@@ -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
} }
} }

View File

@@ -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",

View File

@@ -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
} }
} }

View File

@@ -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
} }
} }

View File

@@ -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?) {