diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ee0e5f..0688d0b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.4.7" -val rosettaVersionCode = 49 // Increment on each release +val rosettaVersionName = "1.4.8" +val rosettaVersionCode = 50 // Increment on each release val customWebRtcAar = file("libs/libwebrtc-custom.aar") android { diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 217fdc3..4dd5c36 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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>(emptyList()) } var startCreateAccountFlow by remember { mutableStateOf(false) } + var preservedMainNavStack by remember { mutableStateOf>(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 = emptyList(), + onNavStackChanged: (List) -> 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>(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) }, @@ -1498,8 +1590,7 @@ fun MainScreen( isVisible = isSearchVisible, onBack = { navStack = navStack.filterNot { it is Screen.Search } }, isDarkTheme = isDarkTheme, - layer = 1, - deferToChildren = true + layer = 1 ) { // Экран поиска SearchScreen( @@ -1690,6 +1781,21 @@ fun MainScreen( ) } + if (isCallScreenVisible) { + // Блокируем любой ввод по нижележащим экранам, пока открыт полноэкранный CallOverlay. + // Иначе тапы могут "пробивать" в чат (иконка звонка, kebab, input и т.д.). + val blockerInteraction = remember { MutableInteractionSource() } + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = blockerInteraction, + indication = null, + onClick = {} + ) + ) + } + CallOverlay( state = callUiState, isDarkTheme = isDarkTheme, @@ -1706,5 +1812,43 @@ fun MainScreen( } } ) + + if (showDiscardProfileChangesDialog) { + AlertDialog( + onDismissRequest = { showDiscardProfileChangesDialog = false }, + containerColor = if (isDarkTheme) Color(0xFF1E1E20) else Color.White, + title = { + Text( + text = "Discard changes?", + color = if (isDarkTheme) Color.White else Color.Black + ) + }, + text = { + Text( + text = "You have unsaved profile changes. If you leave now, they will be lost.", + color = if (isDarkTheme) Color(0xFFB5B5BC) else Color(0xFF5A5A66) + ) + }, + confirmButton = { + TextButton( + onClick = { + showDiscardProfileChangesDialog = false + profileHasUnsavedChanges = false + performProfileBack(discardProfileChangesFromChat) + } + ) { + Text("Discard", color = Color(0xFFFF3B30)) + } + }, + dismissButton = { + TextButton(onClick = { showDiscardProfileChangesDialog = false }) { + Text( + "Stay", + color = if (isDarkTheme) Color(0xFF5FA8FF) else Color(0xFF0D8CF4) + ) + } + } + ) + } } } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 69e19cd..0c76272 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -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> { 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, diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 763c683..a105bc9 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,20 +17,19 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - Звонки и lockscreen - - MainActivity больше не открывается поверх экрана блокировки: чаты не раскрываются без разблокировки устройства - - Во входящем полноэкранном звонке отключено автоматическое снятие keyguard - - Исправлено краткое появление "Unknown" при завершении полноэкранного звонка - - При принятии звонка из push добавлено восстановление auth из локального кеша и ускорена отправка ACCEPT + Синхронизация (как на Desktop) + - Во время sync экран чатов показывает "Updating..." и скрывает шумящие промежуточные индикаторы + - На период синхронизации скрываются badge'ы непрочитанного и requests, чтобы список не "прыгал" - Сеть и протокол - - Добавлено ожидание активной сети перед reconnect (ConnectivityManager callback + timeout fallback) - - Разрешена pre-auth отправка call/WebRTC/ICE пакетов после открытия сокета - - Очередь исходящих пакетов теперь сбрасывается сразу в onOpen и отправляется state-aware + Медиа и вложения + - Исправлен кейс, когда фото уже отправлено, но локально оставалось в ERROR с красным индикатором + - Для исходящих медиа стабилизирован переход статусов: после успешной отправки фиксируется SENT без ложного timeout->ERROR + - Таймаут/ретрай WAITING из БД больше не портит медиа-вложения (применяется только к обычным текстовым ожиданиям) + - Для legacy/неподдерживаемых attachment добавлен desktop-style fallback: + "This attachment is no longer available because it was sent for a previous version of the app." - Стабильность UI - - Crash Details защищён от очень больших логов (без падений при открытии тяжёлых отчётов) - - SharedMedia fast-scroll overlay стабилизирован от NaN/Infinity координат + Группы и UI + - Исправлена геометрия входящих фото в группах: пузырь больше не прилипает к аватарке """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 43edcb1..c1a6ae9 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -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> @@ -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? @@ -508,6 +508,7 @@ interface MessageDao { WHERE account = :account AND from_me = 1 AND delivered = 0 + AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]') AND timestamp >= :minTimestamp ORDER BY timestamp ASC """ @@ -524,6 +525,7 @@ interface MessageDao { WHERE account = :account AND from_me = 1 AND delivered = 0 + AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]') AND timestamp < :maxTimestamp """ ) @@ -629,7 +631,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 +980,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? diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 866f0ff..181e999 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -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 ), '' diff --git a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt index 8791d71..a3e4122 100644 --- a/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/TransportManager.kt @@ -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>(emptyList()) val uploading: StateFlow> = _uploading.asStateFlow() + private val activeUploadCalls = ConcurrentHashMap() private val _downloading = MutableStateFlow>(emptyList()) val downloading: StateFlow> = _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 { cont -> - client.newCall(request).enqueue(object : Callback { + val response = suspendCancellableCoroutine { 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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt index 9059251..dac0590 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -35,19 +34,18 @@ fun SeedPhraseScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5) var seedPhrase by remember { mutableStateOf>(emptyList()) } - var isGenerating by remember { mutableStateOf(true) } var hasCopied by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val clipboardManager = LocalClipboardManager.current LaunchedEffect(Unit) { - delay(100) + // Генерируем фразу сразу, без задержек seedPhrase = CryptoManager.generateSeedPhrase() - isGenerating = false + // Даем микро-паузу, чтобы верстка отрисовалась, и запускаем анимацию + delay(50) visible = true } @@ -59,7 +57,7 @@ fun SeedPhraseScreen( .navigationBarsPadding() ) { Column(modifier = Modifier.fillMaxSize()) { - // Simple top bar + // Top bar Row( modifier = Modifier .fillMaxWidth() @@ -108,126 +106,106 @@ fun SeedPhraseScreen( Spacer(modifier = Modifier.height(32.dp)) - // Two column layout - if (isGenerating) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(300.dp), - contentAlignment = Alignment.Center + // Сетка со словами (без Crossfade и лоадера) + if (seedPhrase.isNotEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - CircularProgressIndicator( - color = PrimaryBlue, - strokeWidth = 2.dp, - modifier = Modifier.size(40.dp) - ) - } - } else { - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 200)) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + // Левая колонка (1-6) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Left column (words 1-6) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - for (i in 0..5) { - AnimatedWordItem( - number = i + 1, - word = seedPhrase[i], - isDarkTheme = isDarkTheme, - visible = visible, - delay = 300 + (i * 50) - ) - } + for (i in 0..5) { + AnimatedWordItem( + number = i + 1, + word = seedPhrase[i], + isDarkTheme = isDarkTheme, + visible = visible, + delay = i * 60 + ) } - - // Right column (words 7-12) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - for (i in 6..11) { - AnimatedWordItem( - number = i + 1, - word = seedPhrase[i], - isDarkTheme = isDarkTheme, - visible = visible, - delay = 300 + (i * 50) - ) - } + } + + // Правая колонка (7-12) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + for (i in 6..11) { + AnimatedWordItem( + number = i + 1, + word = seedPhrase[i], + isDarkTheme = isDarkTheme, + visible = visible, + delay = i * 60 + ) } } } } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(24.dp)) - // Copy button - if (!isGenerating) { - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 600)) + scaleIn( - initialScale = 0.8f, - animationSpec = tween(500, delayMillis = 600) - ) - ) { - TextButton( - onClick = { - clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" "))) - hasCopied = true - scope.launch { - delay(2000) - hasCopied = false - } + // Кнопка Copy + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(400, delayMillis = 800)) + scaleIn( + initialScale = 0.8f, + animationSpec = tween(400, delayMillis = 800, easing = LinearOutSlowInEasing) + ) + ) { + TextButton( + onClick = { + clipboardManager.setText(AnnotatedString(seedPhrase.joinToString(" "))) + hasCopied = true + scope.launch { + delay(2000) + hasCopied = false } - ) { - Icon( - imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy, - contentDescription = null, - tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = if (hasCopied) "Copied" else "Copy to clipboard", - color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue, - fontSize = 15.sp - ) } + ) { + Icon( + imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy, + contentDescription = null, + tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (hasCopied) "Copied" else "Copy to clipboard", + color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue, + fontSize = 15.sp + ) } } Spacer(modifier = Modifier.weight(1f)) - // Continue button + // Кнопка Continue AnimatedVisibility( visible = visible, - enter = fadeIn(tween(400, delayMillis = 700)) + enter = fadeIn(tween(400, delayMillis = 900)) + slideInVertically( + initialOffsetY = { 20 }, + animationSpec = tween(400, delayMillis = 900) + ) ) { Button( onClick = { onConfirm(seedPhrase) }, - enabled = !isGenerating, modifier = Modifier .fillMaxWidth() - .height(50.dp), + .height(52.dp), colors = ButtonDefaults.buttonColors( containerColor = PrimaryBlue, - contentColor = Color.White, - disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), - disabledContentColor = Color.White.copy(alpha = 0.5f) + contentColor = Color.White ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(14.dp) ) { Text( text = "Continue", fontSize = 17.sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.SemiBold ) } } @@ -246,21 +224,11 @@ private fun WordItem( modifier: Modifier = Modifier ) { val numberColor = if (isDarkTheme) Color(0xFF888888) else Color(0xFF999999) - - // Beautiful solid colors that fit the theme val wordColors = listOf( - Color(0xFF5E9FFF), // Soft blue - Color(0xFFFF7EB3), // Soft pink - Color(0xFF7B68EE), // Medium purple - Color(0xFF50C878), // Emerald green - Color(0xFFFF6B6B), // Coral red - Color(0xFF4ECDC4), // Teal - Color(0xFFFFB347), // Pastel orange - Color(0xFFBA55D3), // Medium orchid - Color(0xFF87CEEB), // Sky blue - Color(0xFFDDA0DD), // Plum - Color(0xFF98D8C8), // Mint - Color(0xFFF7DC6F) // Soft yellow + Color(0xFF5E9FFF), Color(0xFFFF7EB3), Color(0xFF7B68EE), + Color(0xFF50C878), Color(0xFFFF6B6B), Color(0xFF4ECDC4), + Color(0xFFFFB347), Color(0xFFBA55D3), Color(0xFF87CEEB), + Color(0xFFDDA0DD), Color(0xFF98D8C8), Color(0xFFF7DC6F) ) val wordColor = wordColors[(number - 1) % wordColors.size] @@ -271,21 +239,18 @@ private fun WordItem( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(bgColor) - .padding(horizontal = 16.dp, vertical = 14.dp) + .padding(horizontal = 14.dp, vertical = 12.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { + Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "$number.", - fontSize = 15.sp, + fontSize = 13.sp, color = numberColor, - modifier = Modifier.width(28.dp) + modifier = Modifier.width(26.dp) ) Text( text = word, - fontSize = 17.sp, + fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = wordColor, fontFamily = FontFamily.Monospace @@ -303,15 +268,21 @@ private fun AnimatedWordItem( delay: Int, modifier: Modifier = Modifier ) { + val overshootEasing = remember { CubicBezierEasing(0.175f, 0.885f, 0.32f, 1.275f) } + AnimatedVisibility( visible = visible, - enter = fadeIn(tween(400, delayMillis = delay)) + enter = fadeIn(animationSpec = tween(300, delayMillis = delay)) + + slideInVertically( + initialOffsetY = { 30 }, + animationSpec = tween(400, delayMillis = delay, easing = FastOutSlowInEasing) + ) + + scaleIn( + initialScale = 0.85f, + animationSpec = tween(400, delayMillis = delay, easing = overshootEasing) + ), + modifier = modifier ) { - WordItem( - number = number, - word = word, - isDarkTheme = isDarkTheme, - modifier = modifier - ) + WordItem(number, word, isDarkTheme) } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt index 6f91b02..4cc58fb 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SelectAccountScreen.kt @@ -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 { } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 7b6d209..9af4799 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -3306,6 +3306,14 @@ fun ChatDetailScreen( message.id ) }, + onCancelPhotoUpload = { + attachmentId -> + viewModel + .cancelOutgoingImageUpload( + message.id, + attachmentId + ) + }, onImageClick = { attachmentId, bounds diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index f7cf91f..e0096cf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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({ it.timestamp.time }, { it.id }) + // Keep sort stable for equal timestamps: tie order comes from existing list/insertion order. + private val chatMessageAscComparator = compareBy { it.timestamp.time } private val chatMessageDescComparator = - compareByDescending { it.timestamp.time }.thenByDescending { it.id } + compareByDescending { it.timestamp.time } private fun sortMessagesAsc(messages: List): List = 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() // 🔥 Throttling для typing индикатора private var lastTypingSentTime = 0L @@ -619,6 +620,50 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { updateCacheFromCurrentMessages() } + /** + * Для исходящих media-сообщений (фото/файл/аватар) не держим "часики" после фактической отправки: + * optimistic WAITING в БД должен отображаться как SENT, если localUri уже очищен. + */ + private fun shouldTreatWaitingAsSent(entity: MessageEntity): Boolean { + if (entity.fromMe != 1 || entity.primaryAttachmentType < 0) return false + + val attachments = parseAttachmentsJsonArray(entity.attachments) ?: return false + if (attachments.length() == 0) return false + + var hasMediaAttachment = false + for (index in 0 until attachments.length()) { + val attachment = attachments.optJSONObject(index) ?: continue + when (parseAttachmentType(attachment)) { + AttachmentType.IMAGE, + AttachmentType.FILE, + AttachmentType.AVATAR -> { + hasMediaAttachment = true + if (attachment.optString("localUri", "").isNotBlank()) { + // Локальный URI ещё есть => загрузка/подготовка не завершена. + return false + } + } + AttachmentType.UNKNOWN -> continue + else -> return false + } + } + + return hasMediaAttachment + } + + private fun mapEntityDeliveryStatus(entity: MessageEntity): MessageStatus { + return when (entity.delivered) { + DeliveryStatus.WAITING.value -> + if (shouldTreatWaitingAsSent(entity)) MessageStatus.SENT + else MessageStatus.SENDING + DeliveryStatus.DELIVERED.value -> + if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED + DeliveryStatus.ERROR.value -> MessageStatus.ERROR + DeliveryStatus.READ.value -> MessageStatus.READ + else -> MessageStatus.SENT + } + } + private fun shortPhotoId(value: String, limit: Int = 8): String { val trimmed = value.trim() if (trimmed.isEmpty()) return "unknown" @@ -1044,14 +1089,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { currentMessages.map { message -> val entity = entitiesById[message.id] ?: return@map message - val dbStatus = - when (entity.delivered) { - 0 -> MessageStatus.SENDING - 1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED - 2 -> MessageStatus.ERROR - 3 -> MessageStatus.READ - else -> MessageStatus.SENT - } + val dbStatus = mapEntityDeliveryStatus(entity) var updatedMessage = message if (updatedMessage.status != dbStatus) { @@ -1370,14 +1408,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { text = displayText, isOutgoing = entity.fromMe == 1, timestamp = Date(entity.timestamp), - status = - when (entity.delivered) { - 0 -> MessageStatus.SENDING - 1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED - 2 -> MessageStatus.ERROR - 3 -> MessageStatus.READ - else -> MessageStatus.SENT - }, + status = mapEntityDeliveryStatus(entity), replyData = if (forwardedMessages.isNotEmpty()) null else replyData, forwardedMessages = forwardedMessages, attachments = finalAttachments, @@ -2579,6 +2610,19 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } + /** 🛑 Отменить исходящую отправку фото во время загрузки */ + fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) { + ProtocolManager.addLog( + "🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}" + ) + outgoingImageUploadJobs.remove(messageId)?.cancel( + CancellationException("User cancelled image upload") + ) + TransportManager.cancelUpload(attachmentId) + ProtocolManager.resolveOutgoingRetry(messageId) + deleteMessage(messageId) + } + /** 🔥 Удалить сообщение (для ошибки отправки) */ fun deleteMessage(messageId: String) { val account = myPublicKey ?: return @@ -3514,7 +3558,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 +3598,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 +3649,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 +3661,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } } + outgoingImageUploadJobs[messageId] = uploadJob + uploadJob.invokeOnCompletion { outgoingImageUploadJobs.remove(messageId) } } /** 🔄 Обновляет optimistic сообщение с реальными данными изображения */ @@ -3655,6 +3706,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { sender: String, privateKey: String ) { + var packetSentToProtocol = false try { val context = getApplication() val pipelineStartedAt = System.currentTimeMillis() @@ -3746,6 +3798,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") @@ -3790,17 +3843,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { logPhotoPipeline(messageId, "db status+attachments updated") withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + updateMessageStatus(messageId, MessageStatus.SENT) // Также очищаем localUri в UI updateMessageAttachments(messageId, null) } - logPhotoPipeline( - messageId, - if (isSavedMessages) "ui status switched to SENT" - else "ui status kept at SENDING until delivery ACK" - ) + logPhotoPipeline(messageId, "ui status switched to SENT") saveDialog( lastMessage = if (caption.isNotEmpty()) caption else "photo", @@ -3811,9 +3858,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) } + } } } @@ -3989,11 +4047,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) - // Для обычных диалогов остаёмся в SENDING до PacketDelivery(messageId). + // После успешной отправки пакета фиксируем SENT (без ложного timeout->ERROR). withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + updateMessageStatus(messageId, MessageStatus.SENT) } saveDialog( @@ -4277,9 +4333,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _messages.value.map { msg -> if (msg.id != messageId) return@map msg msg.copy( - status = - if (isSavedMessages) MessageStatus.SENT - else MessageStatus.SENDING, + status = MessageStatus.SENT, attachments = msg.attachments.map { current -> val final = finalAttachmentsById[current.id] @@ -4510,11 +4564,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) - // Обновляем UI: для обычных чатов оставляем SENDING до PacketDelivery(messageId). + // После успешной отправки медиа переводим в SENT. withContext(Dispatchers.Main) { - if (isSavedMessages) { - updateMessageStatus(messageId, MessageStatus.SENT) - } + updateMessageStatus(messageId, MessageStatus.SENT) } saveDialog( @@ -5469,6 +5521,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { typingNameResolveJob?.cancel() draftSaveJob?.cancel() pinnedCollectionJob?.cancel() + outgoingImageUploadJobs.values.forEach { it.cancel() } + outgoingImageUploadJobs.clear() // 🔥 КРИТИЧНО: Удаляем обработчики пакетов чтобы избежать накопления и дубликатов ProtocolManager.unwaitPacket(0x0B, typingPacketHandler) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 969050a..991ad0e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -610,6 +610,7 @@ fun ChatsListScreen( val topLevelChatsState by chatsViewModel.chatsState.collectAsState() val topLevelIsLoading by chatsViewModel.isLoading.collectAsState() val topLevelRequestsCount = topLevelChatsState.requestsCount + val visibleTopLevelRequestsCount = if (syncInProgress) 0 else topLevelRequestsCount // Dev console dialog - commented out for now /* @@ -1163,7 +1164,7 @@ fun ChatsListScreen( text = "Requests", iconColor = menuIconColor, textColor = menuTextColor, - badge = if (topLevelRequestsCount > 0) topLevelRequestsCount.toString() else null, + badge = if (visibleTopLevelRequestsCount > 0) visibleTopLevelRequestsCount.toString() else null, badgeColor = accentColor, onClick = { scope.launch { @@ -1598,7 +1599,7 @@ fun ChatsListScreen( ) // Badge с числом запросов androidx.compose.animation.AnimatedVisibility( - visible = topLevelRequestsCount > 0, + visible = visibleTopLevelRequestsCount > 0, enter = scaleIn( animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, @@ -1608,10 +1609,10 @@ fun ChatsListScreen( exit = scaleOut() + fadeOut(), modifier = Modifier.align(Alignment.TopEnd) ) { - val badgeText = remember(topLevelRequestsCount) { + val badgeText = remember(visibleTopLevelRequestsCount) { when { - topLevelRequestsCount > 99 -> "99+" - else -> topLevelRequestsCount.toString() + visibleTopLevelRequestsCount > 99 -> "99+" + else -> visibleTopLevelRequestsCount.toString() } } val badgeBg = Color.White @@ -1679,7 +1680,7 @@ fun ChatsListScreen( ) } else if (syncInProgress) { AnimatedDotsText( - baseText = "Synchronizing", + baseText = "Updating", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold @@ -1858,8 +1859,8 @@ fun ChatsListScreen( // независимо val chatsState = topLevelChatsState val isLoading = topLevelIsLoading - val requests = chatsState.requests - val requestsCount = chatsState.requestsCount + val requests = if (syncInProgress) emptyList() else chatsState.requests + val requestsCount = if (syncInProgress) 0 else chatsState.requestsCount val showSkeleton by produceState( @@ -3464,7 +3465,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)) @@ -4117,6 +4119,7 @@ fun DialogItemContent( val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black } val secondaryTextColor = remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) } + val visibleUnreadCount = if (syncInProgress) 0 else dialog.unreadCount val isGroupDialog = remember(dialog.opponentKey) { isGroupDialogKey(dialog.opponentKey) } @@ -4208,7 +4211,8 @@ fun DialogItemContent( avatarRepository = avatarRepository, size = TELEGRAM_DIALOG_AVATAR_SIZE, isDarkTheme = isDarkTheme, - displayName = avatarDisplayName + displayName = avatarDisplayName, + enableBlurPrewarm = true ) } @@ -4448,7 +4452,7 @@ fun DialogItemContent( text = formattedTime, fontSize = 13.sp, color = - if (dialog.unreadCount > 0) PrimaryBlue + if (visibleUnreadCount > 0) PrimaryBlue else secondaryTextColor ) } @@ -4588,7 +4592,7 @@ fun DialogItemContent( baseDisplayText, fontSize = 14.sp, color = - if (dialog.unreadCount > + if (visibleUnreadCount > 0 ) textColor.copy( @@ -4598,7 +4602,7 @@ fun DialogItemContent( else secondaryTextColor, fontWeight = - if (dialog.unreadCount > + if (visibleUnreadCount > 0 ) FontWeight.Medium @@ -4619,11 +4623,11 @@ fun DialogItemContent( text = baseDisplayText, fontSize = 14.sp, color = - if (dialog.unreadCount > 0) + if (visibleUnreadCount > 0) textColor.copy(alpha = 0.85f) else secondaryTextColor, fontWeight = - if (dialog.unreadCount > 0) + if (visibleUnreadCount > 0) FontWeight.Medium else FontWeight.Normal, maxLines = 1, @@ -4658,15 +4662,15 @@ fun DialogItemContent( } // Unread badge - if (dialog.unreadCount > 0) { + if (visibleUnreadCount > 0) { Spacer(modifier = Modifier.width(8.dp)) val unreadText = - remember(dialog.unreadCount) { + remember(visibleUnreadCount) { when { - dialog.unreadCount > 999 -> "999+" - dialog.unreadCount > 99 -> "99+" + visibleUnreadCount > 999 -> "999+" + visibleUnreadCount > 99 -> "99+" else -> - dialog.unreadCount + visibleUnreadCount .toString() } } @@ -4736,7 +4740,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()) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index c222dba..7f9ee46 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -16,6 +16,7 @@ import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import java.util.Locale import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* @@ -391,20 +392,31 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } // Подписываемся на обычные диалоги - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) launch { dialogDao .getDialogsFlow(publicKey) .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) - .map { dialogsList -> - mapDialogListIncremental( - dialogsList = dialogsList, - privateKey = privateKey, - cache = dialogsUiCache, - isRequestsFlow = false - ) + .combine(ProtocolManager.syncInProgress) { dialogsList, syncing -> + dialogsList to syncing } + .mapLatest { (dialogsList, syncing) -> + // Desktop behavior parity: + // while sync is active we keep current chats list stable (no per-message UI churn), + // then apply one consolidated update when sync finishes. + if (syncing && _dialogs.value.isNotEmpty()) { + null + } else { + mapDialogListIncremental( + dialogsList = dialogsList, + privateKey = privateKey, + cache = dialogsUiCache, + isRequestsFlow = false + ) + } + } + .filterNotNull() .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedDialogs -> _dialogs.value = decryptedDialogs @@ -423,19 +435,26 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } // 📬 Подписываемся на requests (запросы от новых пользователей) - @OptIn(FlowPreview::class) + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) launch { dialogDao .getRequestsFlow(publicKey) .flowOn(Dispatchers.IO) .debounce(100) // 🚀 Батчим быстрые обновления - .map { requestsList -> - mapDialogListIncremental( - dialogsList = requestsList, - privateKey = privateKey, - cache = requestsUiCache, - isRequestsFlow = true - ) + .combine(ProtocolManager.syncInProgress) { requestsList, syncing -> + requestsList to syncing + } + .mapLatest { (requestsList, syncing) -> + if (syncing) { + emptyList() + } else { + mapDialogListIncremental( + dialogsList = requestsList, + privateKey = privateKey, + cache = requestsUiCache, + isRequestsFlow = true + ) + } } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки .collect { decryptedRequests -> _requests.value = decryptedRequests } @@ -446,6 +465,9 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio dialogDao .getRequestsCountFlow(publicKey) .flowOn(Dispatchers.IO) + .combine(ProtocolManager.syncInProgress) { count, syncing -> + if (syncing) 0 else count + } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения .collect { count -> _requestsCount.value = count } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 1ffd84f..20bb7d0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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) ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt index c103ec8..76f07d4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -39,12 +40,12 @@ fun RequestsListScreen( avatarRepository: AvatarRepository? = null ) { val chatsState by chatsViewModel.chatsState.collectAsState() - val requests = chatsState.requests + val syncInProgress by ProtocolManager.syncInProgress.collectAsState() + val requests = if (syncInProgress) emptyList() else chatsState.requests val blockedUsers by chatsViewModel.blockedUsers.collectAsState() val scope = rememberCoroutineScope() val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7) val headerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4) - val textColor = if (isDarkTheme) Color.White else Color.Black Scaffold( topBar = { @@ -60,7 +61,7 @@ fun RequestsListScreen( }, title = { Text( - text = "Requests", + text = if (syncInProgress) "Updating..." else "Requests", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Color.White diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index ced4e60..cce6809 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -109,7 +110,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 @@ -238,14 +239,10 @@ fun SearchScreen( } } - Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) { - detectHorizontalDragGestures { _, dragAmount -> - if (dragAmount > 10f) { - hideKeyboardInstantly() - } - } - }) { + Box(modifier = Modifier.fillMaxSize()) { + val hideKbScrollConnection = remember { HideKeyboardNestedScroll(view, focusManager) } Scaffold( + modifier = Modifier.nestedScroll(hideKbScrollConnection), topBar = { // Хедер как в Telegram: стрелка назад + поле ввода Surface( @@ -538,7 +535,7 @@ private fun ChatsTabContent( if (searchQuery.isEmpty()) { // ═══ Idle state: frequent contacts + recent searches ═══ LazyColumn( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize().imePadding() ) { // ─── Горизонтальный ряд частых контактов (как в Telegram) ─── if (visibleFrequentContacts.isNotEmpty()) { @@ -1183,7 +1180,7 @@ private fun MessagesTabContent( } else -> { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().imePadding(), contentPadding = PaddingValues(vertical = 4.dp) ) { items(results, key = { it.messageId }) { result -> @@ -1757,7 +1754,7 @@ private fun DownloadsTabContent( ) } else { LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().imePadding(), contentPadding = PaddingValues(vertical = 4.dp) ) { items(files, key = { it.name }) { downloadedFile -> @@ -1909,7 +1906,7 @@ private fun FilesTabContent( } else { val dateFormat = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().imePadding(), contentPadding = PaddingValues(vertical = 4.dp) ) { items(fileItems, key = { "${it.messageId}_${it.attachmentId}" }) { item -> @@ -2163,3 +2160,21 @@ private fun RecentUserItem( } } } + +/** NestedScrollConnection который скрывает клавиатуру при любом вертикальном скролле */ +private class HideKeyboardNestedScroll( + private val view: android.view.View, + private val focusManager: androidx.compose.ui.focus.FocusManager +) : androidx.compose.ui.input.nestedscroll.NestedScrollConnection { + override fun onPreScroll( + available: androidx.compose.ui.geometry.Offset, + source: androidx.compose.ui.input.nestedscroll.NestedScrollSource + ): androidx.compose.ui.geometry.Offset { + if (kotlin.math.abs(available.y) > 0.5f) { + val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + imm.hideSoftInputFromWindow(view.windowToken, 0) + focusManager.clearFocus() + } + return androidx.compose.ui.geometry.Offset.Zero + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt index 67a03cb..ca7e8f3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/attach/ChatAttachAlert.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index cf23ea4..122ef5b 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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:") @@ -144,6 +145,42 @@ private fun shortDebugHash(bytes: ByteArray): String { } } +private const val LEGACY_ATTACHMENT_ERROR_TEXT = + "This attachment is no longer available because it was sent for a previous version of the app." + +@Composable +private fun LegacyAttachmentErrorCard( + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + val borderColor = if (isDarkTheme) Color(0xFF2A2F38) else Color(0xFFE3E7EF) + val backgroundColor = if (isDarkTheme) Color(0xFF1E232B) else Color(0xFFF7F9FC) + val textColor = if (isDarkTheme) Color(0xFFE9EDF5) else Color(0xFF2B3340) + + Row( + modifier = + modifier.fillMaxWidth() + .border(1.dp, borderColor, RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = Color(0xFFE55A5A), + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = LEGACY_ATTACHMENT_ERROR_TEXT, + color = textColor, + fontSize = 12.sp + ) + } +} + /** * Анимированный текст с волнообразными точками. * Три точки плавно подпрыгивают каскадом с изменением прозрачности. @@ -463,6 +500,7 @@ fun MessageAttachments( showTail: Boolean = true, // Показывать хвостик пузырька 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 +516,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 по отдельности @@ -534,7 +574,8 @@ fun MessageAttachments( ) } else -> { - /* MESSAGES обрабатываются отдельно */ + // Desktop parity: unsupported/legacy attachment gets explicit compatibility card. + LegacyAttachmentErrorCard(isDarkTheme = isDarkTheme) } } } @@ -926,6 +967,11 @@ fun ImageAttachment( var blurhashBitmap by remember(attachment.id) { mutableStateOf(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 +1516,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 + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 02442cb..fabb405 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 = {}, @@ -571,6 +573,7 @@ fun MessageBubble( val telegramIncomingAvatarSize = 42.dp val telegramIncomingAvatarLane = 48.dp val telegramIncomingAvatarInset = 6.dp + val telegramIncomingBubbleGap = 6.dp val shouldShowIncomingGroupAvatar = showIncomingGroupAvatar ?: (isGroupChat && @@ -811,7 +814,13 @@ fun MessageBubble( Box( modifier = Modifier.align(Alignment.Bottom) - .padding(end = 12.dp) + .padding( + start = + if (!message.isOutgoing && isGroupChat) + telegramIncomingBubbleGap + else 0.dp, + end = 12.dp + ) .then(bubbleWidthModifier) .graphicsLayer { this.alpha = selectionAlpha @@ -984,6 +993,7 @@ fun MessageBubble( // пузырька isSelectionMode = isSelectionMode, onLongClick = onLongClick, + onCancelUpload = onCancelPhotoUpload, // В selection mode блокируем открытие фото onImageClick = if (isSelectionMode) { _, _ -> } else onImageClick ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt index 1b2674b..79ab820 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/MediaPickerBottomSheet.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt index 7617a55..14ea0a8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AvatarImage.kt @@ -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 @@ -117,6 +120,18 @@ fun AvatarImage( bitmap = null } } + + 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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index d67581f..cdad6b8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -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(18 * 1024) { + override fun sizeOf(key: String, value: Bitmap): Int = (value.byteCount / 1024).coerceAtLeast(1) + } + private val latestByPublicKey = object : LruCache(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 = 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(null) } - var blurredBitmap by remember { mutableStateOf(null) } + var originalBitmap by remember(publicKey) { mutableStateOf(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: Лёгкое тонирование цветом аватара diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index 281b285..78f5e65 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -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: показываем только если есть нативные кнопки diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index 86d4dee..0b1f410 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -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(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) } - diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt index ca55f62..da1ceaa 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/BiometricEnableScreen.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt new file mode 100644 index 0000000..fbf5c47 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt @@ -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) + ) + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt index fe8c2b1..6c4a1bf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileLogsScreen.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt index fa6910c..88de147 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileScreen.kt @@ -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", diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index 1e46910..1a79615 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt index a382377..9a00de8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/UpdatesScreen.kt @@ -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 } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt b/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt index 1b05fe4..61b419e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/utils/SystemBarsStyleUtils.kt @@ -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?) { diff --git a/app/src/main/res/drawable-nodpi/wallpaper_light_03.png b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png index c69cdca..7abdd15 100644 Binary files a/app/src/main/res/drawable-nodpi/wallpaper_light_03.png and b/app/src/main/res/drawable-nodpi/wallpaper_light_03.png differ