Доработки интерфейса и поведения настроек, профиля и групп
This commit is contained in:
@@ -16,10 +16,15 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.tween
|
||||
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.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
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.settings.BackupScreen
|
||||
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.ProfileScreen
|
||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||
@@ -203,6 +209,8 @@ class MainActivity : FragmentActivity() {
|
||||
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
|
||||
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
|
||||
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
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -303,6 +311,8 @@ class MainActivity : FragmentActivity() {
|
||||
},
|
||||
onLogout = {
|
||||
startCreateAccountFlow = false
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
// Set currentAccount to null immediately to prevent UI
|
||||
// lag
|
||||
currentAccount = null
|
||||
@@ -316,8 +326,27 @@ class MainActivity : FragmentActivity() {
|
||||
)
|
||||
}
|
||||
"main" -> {
|
||||
val activeAccountKey = currentAccount?.publicKey.orEmpty()
|
||||
MainScreen(
|
||||
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,
|
||||
themeMode = themeMode,
|
||||
onToggleTheme = {
|
||||
@@ -331,6 +360,8 @@ class MainActivity : FragmentActivity() {
|
||||
},
|
||||
onLogout = {
|
||||
startCreateAccountFlow = false
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
// Set currentAccount to null immediately to prevent UI
|
||||
// lag
|
||||
currentAccount = null
|
||||
@@ -343,6 +374,8 @@ class MainActivity : FragmentActivity() {
|
||||
},
|
||||
onDeleteAccount = {
|
||||
startCreateAccountFlow = false
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
val publicKey = currentAccount?.publicKey ?: return@MainScreen
|
||||
scope.launch {
|
||||
try {
|
||||
@@ -405,6 +438,8 @@ class MainActivity : FragmentActivity() {
|
||||
hasExistingAccount = accounts.isNotEmpty()
|
||||
// 9. If current account is deleted, return to main login screen
|
||||
if (currentAccount?.publicKey == targetPublicKey) {
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
}
|
||||
@@ -415,6 +450,8 @@ class MainActivity : FragmentActivity() {
|
||||
},
|
||||
onSwitchAccount = { targetPublicKey ->
|
||||
startCreateAccountFlow = false
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
// Save target account before leaving main screen so Unlock
|
||||
// screen preselects the account the user tapped.
|
||||
accountManager.setLastLoggedPublicKey(targetPublicKey)
|
||||
@@ -429,6 +466,8 @@ class MainActivity : FragmentActivity() {
|
||||
},
|
||||
onAddAccount = {
|
||||
startCreateAccountFlow = true
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
scope.launch {
|
||||
@@ -442,6 +481,8 @@ class MainActivity : FragmentActivity() {
|
||||
DeviceConfirmScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
onExit = {
|
||||
preservedMainNavStack = emptyList()
|
||||
preservedMainNavAccountKey = ""
|
||||
currentAccount = null
|
||||
clearCachedSessionAccount()
|
||||
scope.launch {
|
||||
@@ -614,6 +655,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
|
||||
sealed class Screen {
|
||||
data object Profile : Screen()
|
||||
data object ProfileFromChat : Screen()
|
||||
data object Notifications : Screen()
|
||||
data object Requests : Screen()
|
||||
data object Search : Screen()
|
||||
data object GroupSetup : Screen()
|
||||
@@ -634,6 +676,8 @@ sealed class Screen {
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
account: DecryptedAccount? = null,
|
||||
initialNavStack: List<Screen> = emptyList(),
|
||||
onNavStackChanged: (List<Screen>) -> Unit = {},
|
||||
isDarkTheme: Boolean = true,
|
||||
themeMode: String = "dark",
|
||||
onToggleTheme: () -> Unit = {},
|
||||
@@ -941,7 +985,8 @@ fun MainScreen(
|
||||
// navigation change. This eliminates the massive recomposition
|
||||
// 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
|
||||
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
|
||||
@@ -949,6 +994,9 @@ fun MainScreen(
|
||||
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
|
||||
}
|
||||
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 isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
|
||||
val chatDetailScreen by remember {
|
||||
@@ -980,6 +1028,9 @@ fun MainScreen(
|
||||
val isAppearanceVisible by remember {
|
||||
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
|
||||
fun pushScreen(screen: Screen) {
|
||||
@@ -1027,7 +1078,8 @@ fun MainScreen(
|
||||
navStack =
|
||||
navStack.filterNot {
|
||||
it is Screen.Profile ||
|
||||
it is Screen.Theme ||
|
||||
it is Screen.Theme ||
|
||||
it is Screen.Notifications ||
|
||||
it is Screen.Safety ||
|
||||
it is Screen.Backup ||
|
||||
it is Screen.Logs ||
|
||||
@@ -1036,6 +1088,21 @@ fun MainScreen(
|
||||
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() {
|
||||
navStack =
|
||||
navStack.filterNot {
|
||||
@@ -1043,6 +1110,13 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(isProfileVisible, isProfileFromChatVisible) {
|
||||
if (!isProfileVisible && !isProfileFromChatVisible) {
|
||||
profileHasUnsavedChanges = false
|
||||
showDiscardProfileChangesDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
// ProfileViewModel для логов
|
||||
val profileViewModel: com.rosetta.messenger.ui.settings.ProfileViewModel =
|
||||
androidx.lifecycle.viewmodel.compose.viewModel()
|
||||
@@ -1196,9 +1270,10 @@ fun MainScreen(
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
SwipeBackContainer(
|
||||
isVisible = isProfileVisible,
|
||||
onBack = { popProfileAndChildren() },
|
||||
onBack = { requestProfileBack(fromChat = false) },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1,
|
||||
swipeEnabled = !profileHasUnsavedChanges,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
// Экран профиля
|
||||
@@ -1209,13 +1284,15 @@ fun MainScreen(
|
||||
accountVerified = accountVerified,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKeyHash = privateKeyHash,
|
||||
onBack = { popProfileAndChildren() },
|
||||
onBack = { requestProfileBack(fromChat = false) },
|
||||
onHasChangesChanged = { profileHasUnsavedChanges = it },
|
||||
onSaveProfile = { name, username ->
|
||||
accountName = name
|
||||
accountUsername = username
|
||||
mainScreenScope.launch { onAccountInfoUpdated() }
|
||||
},
|
||||
onLogout = onLogout,
|
||||
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
|
||||
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
||||
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
||||
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
||||
@@ -1229,6 +1306,18 @@ fun MainScreen(
|
||||
}
|
||||
|
||||
// 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(
|
||||
isVisible = isSafetyVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
|
||||
@@ -1420,9 +1509,10 @@ fun MainScreen(
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = isProfileFromChatVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
|
||||
onBack = { requestProfileBack(fromChat = true) },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1,
|
||||
swipeEnabled = !profileHasUnsavedChanges,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
ProfileScreen(
|
||||
@@ -1432,13 +1522,15 @@ fun MainScreen(
|
||||
accountVerified = accountVerified,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKeyHash = privateKeyHash,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
|
||||
onBack = { requestProfileBack(fromChat = true) },
|
||||
onHasChangesChanged = { profileHasUnsavedChanges = it },
|
||||
onSaveProfile = { name, username ->
|
||||
accountName = name
|
||||
accountUsername = username
|
||||
mainScreenScope.launch { onAccountInfoUpdated() }
|
||||
},
|
||||
onLogout = onLogout,
|
||||
onNavigateToNotifications = { pushScreen(Screen.Notifications) },
|
||||
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
||||
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
||||
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(
|
||||
state = callUiState,
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,6 +448,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
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>> {
|
||||
val dialogKey = getDialogKey(opponentKey)
|
||||
@@ -711,6 +723,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
val isOwnMessage = packet.fromPublicKey == account
|
||||
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)) {
|
||||
@@ -911,7 +930,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
fromPublicKey = packet.fromPublicKey,
|
||||
toPublicKey = packet.toPublicKey,
|
||||
content = packet.content,
|
||||
timestamp = packet.timestamp,
|
||||
timestamp = normalizedPacketTimestamp,
|
||||
chachaKey = storedChachaKey,
|
||||
read = 0,
|
||||
fromMe = if (isOwnMessage) 1 else 0,
|
||||
|
||||
@@ -238,7 +238,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account AND dialog_key = :dialogKey
|
||||
ORDER BY timestamp DESC, message_id DESC
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
@@ -260,7 +260,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND from_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
|
||||
"""
|
||||
)
|
||||
@@ -286,7 +286,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
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>>
|
||||
@@ -319,7 +319,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account AND dialog_key = :dialogKey
|
||||
ORDER BY timestamp DESC, message_id DESC
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
@@ -378,7 +378,7 @@ interface MessageDao {
|
||||
AND dialog_key = :dialogKey
|
||||
AND from_public_key = :fromPublicKey
|
||||
AND timestamp BETWEEN :timestampFrom AND :timestampTo
|
||||
ORDER BY timestamp ASC, message_id ASC
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
@@ -462,7 +462,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||
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?
|
||||
@@ -477,7 +477,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||
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?
|
||||
@@ -492,7 +492,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||
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?
|
||||
@@ -629,7 +629,7 @@ interface MessageDao {
|
||||
END
|
||||
WHERE m.account = :account
|
||||
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
|
||||
"""
|
||||
)
|
||||
@@ -978,7 +978,7 @@ interface DialogDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
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?
|
||||
|
||||
@@ -332,7 +332,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
THEN dialogs.account || ':' || dialogs.opponent_key
|
||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||
END
|
||||
ORDER BY m.timestamp DESC, m.message_id DESC
|
||||
ORDER BY m.timestamp DESC, m.id DESC
|
||||
LIMIT 1
|
||||
),
|
||||
''
|
||||
|
||||
@@ -19,7 +19,6 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
@@ -44,6 +43,7 @@ object TransportManager {
|
||||
|
||||
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
|
||||
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
|
||||
|
||||
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||
@@ -133,6 +133,14 @@ object TransportManager {
|
||||
_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 =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val call = client.newCall(request)
|
||||
@@ -224,13 +232,31 @@ object TransportManager {
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = suspendCoroutine<Response> { cont ->
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
val response = suspendCancellableCoroutine<Response> { cont ->
|
||||
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) {
|
||||
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) {
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (cont.isCancelled) {
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
@@ -253,12 +279,16 @@ object TransportManager {
|
||||
|
||||
tag
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
ProtocolManager.addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeUploadCalls.remove(id)?.cancel()
|
||||
// Удаляем из списка загрузок
|
||||
_uploading.value = _uploading.value.filter { it.id != id }
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ fun SelectAccountScreen(
|
||||
DisposableEffect(isDarkTheme) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
onDispose { }
|
||||
}
|
||||
|
||||
|
||||
@@ -3306,6 +3306,14 @@ fun ChatDetailScreen(
|
||||
message.id
|
||||
)
|
||||
},
|
||||
onCancelPhotoUpload = {
|
||||
attachmentId ->
|
||||
viewModel
|
||||
.cancelOutgoingImageUpload(
|
||||
message.id,
|
||||
attachmentId
|
||||
)
|
||||
},
|
||||
onImageClick = {
|
||||
attachmentId,
|
||||
bounds
|
||||
|
||||
@@ -45,10 +45,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private const val MAX_CACHE_SIZE = 500 // 🔥 Максимум сообщений в кэше для защиты от OOM
|
||||
private const val DRAFT_SAVE_DEBOUNCE_MS = 250L
|
||||
private val backgroundUploadScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val chatMessageAscComparator =
|
||||
compareBy<ChatMessage>({ it.timestamp.time }, { it.id })
|
||||
// Keep sort stable for equal timestamps: tie order comes from existing list/insertion order.
|
||||
private val chatMessageAscComparator = compareBy<ChatMessage> { it.timestamp.time }
|
||||
private val chatMessageDescComparator =
|
||||
compareByDescending<ChatMessage> { it.timestamp.time }.thenByDescending { it.id }
|
||||
compareByDescending<ChatMessage> { it.timestamp.time }
|
||||
|
||||
private fun sortMessagesAsc(messages: List<ChatMessage>): List<ChatMessage> =
|
||||
messages.sortedWith(chatMessageAscComparator)
|
||||
@@ -227,6 +227,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Job для отмены загрузки при смене диалога
|
||||
private var loadingJob: Job? = null
|
||||
private var draftSaveJob: Job? = null
|
||||
private val outgoingImageUploadJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// 🔥 Throttling для typing индикатора
|
||||
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) {
|
||||
val account = myPublicKey ?: return
|
||||
@@ -3514,7 +3528,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 2. 🔄 В фоне, независимо от жизненного цикла экрана:
|
||||
// сохраняем optimistic в БД -> конвертируем -> загружаем -> отправляем пакет.
|
||||
backgroundUploadScope.launch {
|
||||
val uploadJob = backgroundUploadScope.launch {
|
||||
try {
|
||||
logPhotoPipeline(messageId, "persist optimistic message in DB")
|
||||
val optimisticAttachmentsJson =
|
||||
@@ -3554,6 +3568,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
opponentPublicKey = recipient
|
||||
)
|
||||
logPhotoPipeline(messageId, "optimistic dialog updated")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
logPhotoPipeline(messageId, "optimistic DB save skipped (non-fatal)")
|
||||
}
|
||||
@@ -3603,6 +3619,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
privateKey = privateKey
|
||||
)
|
||||
logPhotoPipeline(messageId, "pipeline completed")
|
||||
} catch (e: CancellationException) {
|
||||
logPhotoPipeline(messageId, "pipeline cancelled by user")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logPhotoPipelineError(messageId, "prepare+convert", e)
|
||||
if (!isCleared) {
|
||||
@@ -3612,6 +3631,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
}
|
||||
outgoingImageUploadJobs[messageId] = uploadJob
|
||||
uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) }
|
||||
}
|
||||
|
||||
/** 🔄 Обновляет optimistic сообщение с реальными данными изображения */
|
||||
@@ -3655,6 +3676,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
sender: String,
|
||||
privateKey: String
|
||||
) {
|
||||
var packetSentToProtocol = false
|
||||
try {
|
||||
val context = getApplication<Application>()
|
||||
val pipelineStartedAt = System.currentTimeMillis()
|
||||
@@ -3746,6 +3768,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Отправляем пакет
|
||||
if (!isSavedMessages) {
|
||||
ProtocolManager.send(packet)
|
||||
packetSentToProtocol = true
|
||||
logPhotoPipeline(messageId, "packet sent to protocol")
|
||||
} else {
|
||||
logPhotoPipeline(messageId, "saved-messages mode: packet send skipped")
|
||||
@@ -3811,9 +3834,20 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
messageId,
|
||||
"dialog updated; totalElapsed=${System.currentTimeMillis() - pipelineStartedAt}ms"
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
logPhotoPipeline(messageId, "internal-send cancelled")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
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()
|
||||
draftSaveJob?.cancel()
|
||||
pinnedCollectionJob?.cancel()
|
||||
outgoingImageUploadJobs.values.forEach { it.cancel() }
|
||||
outgoingImageUploadJobs.clear()
|
||||
|
||||
// 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов
|
||||
ProtocolManager.unwaitPacket(0x0B, typingPacketHandler)
|
||||
|
||||
@@ -3464,7 +3464,8 @@ fun ChatItem(
|
||||
isDarkTheme = isDarkTheme,
|
||||
showOnlineIndicator = true,
|
||||
isOnline = chat.isOnline,
|
||||
displayName = chat.name // 🔥 Для инициалов
|
||||
displayName = chat.name, // 🔥 Для инициалов
|
||||
enableBlurPrewarm = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
|
||||
@@ -4208,7 +4209,8 @@ fun DialogItemContent(
|
||||
avatarRepository = avatarRepository,
|
||||
size = TELEGRAM_DIALOG_AVATAR_SIZE,
|
||||
isDarkTheme = isDarkTheme,
|
||||
displayName = avatarDisplayName
|
||||
displayName = avatarDisplayName,
|
||||
enableBlurPrewarm = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4736,7 +4738,7 @@ fun TypingIndicatorSmall(
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = ""
|
||||
) {
|
||||
val typingColor = PrimaryBlue
|
||||
val typingColor = if (isDarkTheme) PrimaryBlue else Color(0xFF34C759)
|
||||
val senderTypingColor =
|
||||
remember(typingSenderPublicKey, isDarkTheme) {
|
||||
if (typingSenderPublicKey.isBlank()) {
|
||||
|
||||
@@ -150,6 +150,8 @@ fun GroupSetupScreen(
|
||||
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = Color(0xFF8E8E93)
|
||||
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 =
|
||||
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
|
||||
val groupAvatarCameraIconColor =
|
||||
@@ -745,8 +747,8 @@ fun GroupSetupScreen(
|
||||
isLoading = false
|
||||
}
|
||||
},
|
||||
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
|
||||
contentColor = Color.White,
|
||||
containerColor = if (actionEnabled) accentColor else disabledActionColor,
|
||||
contentColor = if (actionEnabled) Color.White else disabledActionContentColor,
|
||||
shape = CircleShape,
|
||||
modifier = run {
|
||||
// Берём максимум из всех позиций — при переключении keyboard↔emoji
|
||||
@@ -762,7 +764,7 @@ fun GroupSetupScreen(
|
||||
) {
|
||||
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
|
||||
CircularProgressIndicator(
|
||||
color = Color.White,
|
||||
color = if (actionEnabled) Color.White else disabledActionContentColor,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ fun SearchScreen(
|
||||
DisposableEffect(isDarkTheme) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
onDispose {
|
||||
// Restore white status bar icons for chat list header
|
||||
|
||||
@@ -765,7 +765,7 @@ fun ChatAttachAlert(
|
||||
// as the popup overlay, so top area and content overlay always match.
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
} else {
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
var lastAppliedAlpha = -1
|
||||
|
||||
@@ -86,6 +86,7 @@ import kotlin.math.min
|
||||
private const val TAG = "AttachmentComponents"
|
||||
private const val MAX_BITMAP_DECODE_DIMENSION = 4096
|
||||
private val whitespaceRegex = "\\s+".toRegex()
|
||||
private val LocalOnCancelImageUpload = staticCompositionLocalOf<(String) -> Unit> { {} }
|
||||
|
||||
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||
|
||||
@@ -463,6 +464,7 @@ fun MessageAttachments(
|
||||
showTail: Boolean = true, // Показывать хвостик пузырька
|
||||
isSelectionMode: Boolean = false, // Блокировать клик на фото в selection mode
|
||||
onLongClick: () -> Unit = {}, // Long press на фото — запускает selection mode
|
||||
onCancelUpload: (attachmentId: String) -> Unit = {},
|
||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@@ -478,21 +480,23 @@ fun MessageAttachments(
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// 🖼️ Коллаж для изображений (если больше 1)
|
||||
if (imageAttachments.isNotEmpty()) {
|
||||
ImageCollage(
|
||||
attachments = imageAttachments,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
hasCaption = hasCaption,
|
||||
showTail = showTail,
|
||||
isSelectionMode = isSelectionMode,
|
||||
onLongClick = onLongClick,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
CompositionLocalProvider(LocalOnCancelImageUpload provides onCancelUpload) {
|
||||
ImageCollage(
|
||||
attachments = imageAttachments,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
senderPublicKey = senderPublicKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus,
|
||||
hasCaption = hasCaption,
|
||||
showTail = showTail,
|
||||
isSelectionMode = isSelectionMode,
|
||||
onLongClick = onLongClick,
|
||||
onImageClick = onImageClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Остальные attachments по отдельности
|
||||
@@ -926,6 +930,11 @@ fun ImageAttachment(
|
||||
var blurhashBitmap by remember(attachment.id) { mutableStateOf<Bitmap?>(null) }
|
||||
var downloadProgress by remember(attachment.id) { mutableStateOf(0f) }
|
||||
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 downloadTag = getDownloadTag(attachment)
|
||||
@@ -1470,26 +1479,75 @@ fun ImageAttachment(
|
||||
}
|
||||
}
|
||||
|
||||
// ✈️ Telegram-style: Loader при отправке фото (самолётик/кружок)
|
||||
// Показываем когда сообщение отправляется И это исходящее сообщение
|
||||
if (isOutgoing && messageStatus == MessageStatus.SENDING && downloadStatus == DownloadStatus.DOWNLOADED) {
|
||||
// Desktop-style upload state: Encrypting... (0%) / progress ring (>0%)
|
||||
if (isOutgoing &&
|
||||
messageStatus == MessageStatus.SENDING &&
|
||||
downloadStatus == DownloadStatus.DOWNLOADED &&
|
||||
(isUploadInProgress || attachment.localUri.isNotEmpty())
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.35f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
// Круглый индикатор отправки
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(28.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.5.dp
|
||||
)
|
||||
if (uploadProgress > 0) {
|
||||
val cappedProgress = uploadProgress.coerceIn(1, 95) / 100f
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color.Black.copy(alpha = 0.5f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
enabled = !isSelectionMode
|
||||
) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,8 @@ fun TypingIndicator(
|
||||
typingDisplayName: String = "",
|
||||
typingSenderPublicKey: String = ""
|
||||
) {
|
||||
val typingColor = Color(0xFF54A9EB)
|
||||
val typingColor =
|
||||
if (isDarkTheme) Color(0xFF54A9EB) else Color.White.copy(alpha = 0.7f)
|
||||
val senderTypingColor =
|
||||
remember(typingSenderPublicKey, isDarkTheme) {
|
||||
if (typingSenderPublicKey.isBlank()) {
|
||||
@@ -345,6 +346,7 @@ fun MessageBubble(
|
||||
onReplyClick: (String) -> Unit = {},
|
||||
onRetry: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onCancelPhotoUpload: (attachmentId: String) -> Unit = {},
|
||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||
onAvatarClick: (senderPublicKey: String) -> Unit = {},
|
||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||
@@ -984,6 +986,7 @@ fun MessageBubble(
|
||||
// пузырька
|
||||
isSelectionMode = isSelectionMode,
|
||||
onLongClick = onLongClick,
|
||||
onCancelUpload = onCancelPhotoUpload,
|
||||
// В selection mode блокируем открытие фото
|
||||
onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick
|
||||
)
|
||||
|
||||
@@ -631,7 +631,7 @@ fun MediaPickerBottomSheet(
|
||||
// Telegram-like natural dim: status-bar tint follows picker scrim alpha.
|
||||
if (fullScreen) {
|
||||
window.statusBarColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
|
||||
insetsController?.isAppearanceLightStatusBars = !dark
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
} else {
|
||||
insetsController?.isAppearanceLightStatusBars = false
|
||||
var lastAppliedAlpha = -1
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
@@ -76,8 +77,10 @@ fun AvatarImage(
|
||||
showOnlineIndicator: Boolean = false,
|
||||
isOnline: Boolean = false,
|
||||
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 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(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Shader
|
||||
import android.util.LruCache
|
||||
import android.os.Build
|
||||
import android.renderscript.Allocation
|
||||
import android.renderscript.Element
|
||||
@@ -38,6 +39,51 @@ import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val hasColorOverlay = overlayColors != null && overlayColors.isNotEmpty()
|
||||
|
||||
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
|
||||
?: remember { mutableStateOf(emptyList()) }
|
||||
@@ -62,11 +109,24 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
val avatarKey = remember(avatars) {
|
||||
avatars.firstOrNull()?.timestamp ?: 0L
|
||||
}
|
||||
val blurCacheKey = remember(publicKey, avatarKey) { "$publicKey:$avatarKey" }
|
||||
|
||||
var originalBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
var originalBitmap by remember(publicKey) { 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 newOriginal = withContext(Dispatchers.IO) {
|
||||
// Keep system account blur source identical to the visible avatar drawable.
|
||||
@@ -81,46 +141,36 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
}
|
||||
if (newOriginal != null) {
|
||||
originalBitmap = newOriginal
|
||||
blurredBitmap = withContext(Dispatchers.Default) {
|
||||
gaussianBlur(context, newOriginal, radius = 20f, passes = 2)
|
||||
val blurred = withContext(Dispatchers.Default) {
|
||||
gaussianBlur(context, newOriginal, radius = blurRadius, passes = 2)
|
||||
}
|
||||
} else {
|
||||
blurredBitmap = blurred
|
||||
BlurredAvatarMemoryCache.put(blurCacheKey, blurred)
|
||||
} else if (blurredBitmap == null) {
|
||||
originalBitmap = null
|
||||
blurredBitmap = null
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Режим с overlay-цветом — blur + пастельный overlay
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (overlayColors != null && overlayColors.isNotEmpty()) {
|
||||
// LAYER 1: Blurred avatar (яркий, видный)
|
||||
if (blurredBitmap != null) {
|
||||
Image(
|
||||
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) {
|
||||
if (hasColorOverlay) {
|
||||
val overlayPalette = overlayColors.orEmpty()
|
||||
// LAYER 1: Pure color/gradient background (no avatar blur behind avatar).
|
||||
val overlayMod = if (overlayPalette.size == 1) {
|
||||
Modifier.matchParentSize()
|
||||
.background(overlayColors[0].copy(alpha = colorAlpha))
|
||||
.background(overlayPalette[0])
|
||||
} else {
|
||||
Modifier.matchParentSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = overlayColors.map { it.copy(alpha = colorAlpha) }
|
||||
colors = overlayPalette
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(modifier = overlayMod)
|
||||
|
||||
// LAYER 3: Нижний градиент для читаемости текста
|
||||
// LAYER 2: Нижний градиент для читаемости текста
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
@@ -141,13 +191,14 @@ fun BoxScope.BlurredAvatarBackground(
|
||||
// Стандартный режим (нет overlay-цвета) — blur аватарки
|
||||
// Telegram-style: яркий видный блюр + мягкое затемнение
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
if (blurredBitmap != null) {
|
||||
val displayBitmap = blurredBitmap ?: originalBitmap
|
||||
if (displayBitmap != null) {
|
||||
// LAYER 1: Размытая аватарка — яркая и видная
|
||||
Image(
|
||||
bitmap = blurredBitmap!!.asImageBitmap(),
|
||||
bitmap = displayBitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize()
|
||||
.graphicsLayer { this.alpha = 0.9f },
|
||||
.graphicsLayer { this.alpha = if (blurredBitmap != null) 0.9f else 0.72f },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
// LAYER 2: Лёгкое тонирование цветом аватара
|
||||
|
||||
@@ -159,7 +159,7 @@ fun OnboardingScreen(
|
||||
if (shouldUpdateStatusBar && !view.isInEditMode) {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
|
||||
// Navigation bar: показываем только если есть нативные кнопки
|
||||
|
||||
@@ -87,7 +87,7 @@ fun AppearanceScreen(
|
||||
SideEffect {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,7 @@ private fun ProfileBlurPreview(
|
||||
) {
|
||||
val option = BackgroundBlurPresets.findById(selectedId)
|
||||
val overlayColors = BackgroundBlurPresets.getOverlayColors(selectedId)
|
||||
val shouldUseAvatarBlur = overlayColors.isNullOrEmpty() && selectedId != "none"
|
||||
val avatarColors = getAvatarColor(publicKey, isDarkTheme)
|
||||
|
||||
// Загрузка аватарки
|
||||
@@ -344,7 +345,7 @@ private fun ProfileBlurPreview(
|
||||
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
val blurContext = LocalContext.current
|
||||
|
||||
LaunchedEffect(avatarKey) {
|
||||
LaunchedEffect(avatarKey, shouldUseAvatarBlur) {
|
||||
val current = avatars
|
||||
if (current.isNotEmpty()) {
|
||||
val decoded = withContext(Dispatchers.IO) {
|
||||
@@ -352,9 +353,14 @@ private fun ProfileBlurPreview(
|
||||
}
|
||||
if (decoded != null) {
|
||||
avatarBitmap = decoded
|
||||
blurredBitmap = withContext(Dispatchers.Default) {
|
||||
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
|
||||
}
|
||||
blurredBitmap =
|
||||
if (shouldUseAvatarBlur) {
|
||||
withContext(Dispatchers.Default) {
|
||||
appearanceGaussianBlur(blurContext, decoded, radius = 20f, passes = 2)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} else {
|
||||
avatarBitmap = null
|
||||
@@ -375,9 +381,8 @@ private fun ProfileBlurPreview(
|
||||
// ═══════════════════════════════════════════════════
|
||||
// LAYER 1: Blurred avatar background (идентично BlurredAvatarBackground)
|
||||
// ═══════════════════════════════════════════════════
|
||||
if (blurredBitmap != null) {
|
||||
// overlay-режим: 0.85f, стандартный: 0.9f (как в BlurredAvatarBackground)
|
||||
val blurImgAlpha = if (overlayColors != null && overlayColors.isNotEmpty()) 0.85f else 0.9f
|
||||
if (shouldUseAvatarBlur && blurredBitmap != null) {
|
||||
val blurImgAlpha = 0.9f
|
||||
Image(
|
||||
bitmap = blurredBitmap!!.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
@@ -402,20 +407,18 @@ private fun ProfileBlurPreview(
|
||||
val overlayMod = if (overlayColors.size == 1) {
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(overlayColors[0].copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f))
|
||||
.background(overlayColors[0])
|
||||
} else {
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = overlayColors.map {
|
||||
it.copy(alpha = if (blurredBitmap != null) 0.4f else 0.85f)
|
||||
}
|
||||
colors = overlayColors
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(modifier = overlayMod)
|
||||
} else if (blurredBitmap != null) {
|
||||
} else if (shouldUseAvatarBlur && blurredBitmap != null) {
|
||||
// Стандартный тинт (идентичен BlurredAvatarBackground)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -782,4 +785,3 @@ private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Flo
|
||||
rs.destroy()
|
||||
return Bitmap.createBitmap(current, pad, pad, w, h)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ fun BiometricEnableScreen(
|
||||
SideEffect {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
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 {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,8 +275,10 @@ fun ProfileScreen(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKeyHash: String,
|
||||
onBack: () -> Unit,
|
||||
onHasChangesChanged: (Boolean) -> Unit = {},
|
||||
onSaveProfile: (name: String, username: String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onNavigateToNotifications: () -> Unit = {},
|
||||
onNavigateToTheme: () -> Unit = {},
|
||||
onNavigateToAppearance: () -> Unit = {},
|
||||
onNavigateToSafety: () -> Unit = {},
|
||||
@@ -402,7 +404,7 @@ fun ProfileScreen(
|
||||
// State for editing - Update when account data changes
|
||||
var editedName by remember(accountName) { mutableStateOf(accountName) }
|
||||
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 usernameTouched 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
|
||||
viewModel.updateLocalProfile(accountPublicKey, editedName, editedUsername)
|
||||
|
||||
hasChanges = false
|
||||
serverNameError = null
|
||||
serverUsernameError = null
|
||||
serverGeneralError = null
|
||||
@@ -743,9 +744,14 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Update hasChanges when fields change
|
||||
LaunchedEffect(editedName, editedUsername) {
|
||||
hasChanges = editedName != accountName || editedUsername != accountUsername
|
||||
SideEffect {
|
||||
onHasChangesChanged(hasChanges)
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onHasChangesChanged(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back button press - navigate to chat list instead of closing app
|
||||
@@ -838,49 +844,19 @@ fun ProfileScreen(
|
||||
|
||||
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
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
TelegramSectionTitle(title = "Settings", isDarkTheme = isDarkTheme)
|
||||
|
||||
TelegramSettingsItem(
|
||||
icon = TelegramIcons.Notifications,
|
||||
title = "Notifications",
|
||||
onClick = onNavigateToNotifications,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showDivider = true
|
||||
)
|
||||
|
||||
TelegramSettingsItem(
|
||||
icon = TelegramIcons.Theme,
|
||||
title = "Theme",
|
||||
|
||||
@@ -78,7 +78,7 @@ fun ThemeScreen(
|
||||
SideEffect {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fun UpdatesScreen(
|
||||
SideEffect {
|
||||
val window = (view.context as android.app.Activity).window
|
||||
val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view)
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ object SystemBarsStyleUtils {
|
||||
if (window == null || view == null) return
|
||||
val insetsController = WindowCompat.getInsetsController(window, view)
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
insetsController.isAppearanceLightStatusBars = !isDarkTheme
|
||||
insetsController.isAppearanceLightStatusBars = false
|
||||
}
|
||||
|
||||
fun restoreNavigationBar(window: Window?, view: View?, context: Context, state: SystemBarsState?) {
|
||||
|
||||
Reference in New Issue
Block a user