From a3810af4a092524e681bdb426bc25e8e6f77f1b2 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 17 Jan 2026 21:09:47 +0500 Subject: [PATCH] Refactor UI components in ChatsListScreen, ForwardChatPickerBottomSheet, and SearchScreen for improved readability and maintainability; adjust text color alpha values, streamline imports, and enhance keyboard handling functionality. --- app/build.gradle.kts | 7 +- app/proguard-rules.pro | 164 + .../keyboard/KeyboardTransitionCoordinator.kt | 278 +- .../com/rosetta/messenger/MainActivity.kt | 2 +- .../messenger/ui/auth/SetPasswordScreen.kt | 800 ++-- .../rosetta/messenger/ui/auth/UnlockScreen.kt | 887 ++-- .../messenger/ui/auth/WelcomeScreen.kt | 368 +- .../messenger/ui/chats/ChatDetailScreen.kt | 3659 +++++++++-------- .../messenger/ui/chats/ChatsListScreen.kt | 38 +- .../ui/chats/ForwardChatPickerBottomSheet.kt | 337 +- .../messenger/ui/chats/SearchScreen.kt | 449 +- 11 files changed, 3763 insertions(+), 3226 deletions(-) create mode 100644 app/proguard-rules.pro diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77232e4..6dc00f7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,7 +18,7 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } - + // Optimize Lottie animations manifestPlaceholders["enableLottieOptimizations"] = "true" } @@ -93,7 +93,7 @@ dependencies { // Crypto libraries for key generation implementation("org.bitcoinj:bitcoinj-core:0.16.2") implementation("org.bouncycastle:bcprov-jdk15to18:1.77") - + // Google Tink for XChaCha20-Poly1305 implementation("com.google.crypto.tink:tink-android:1.10.0") @@ -121,7 +121,7 @@ dependencies { testImplementation("io.mockk:mockk:1.13.8") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("androidx.arch.core:core-testing:2.2.0") - + androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) @@ -129,3 +129,4 @@ dependencies { debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..acb3b44 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,164 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +# ============================================================ +# Firebase Cloud Messaging +# ============================================================ +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.firebase.** +-dontwarn com.google.android.gms.** + +# Keep FirebaseMessagingService +-keep class * extends com.google.firebase.messaging.FirebaseMessagingService { + *; +} +-keep class com.rosetta.messenger.push.RosettaFirebaseMessagingService { *; } + +# Keep notification data models +-keepclassmembers class * { + @com.google.firebase.messaging.RemoteMessage$MessageNotificationKeys ; +} + +# ============================================================ +# Kotlin & Coroutines +# ============================================================ +-keep class kotlinx.coroutines.** { *; } +-dontwarn kotlinx.coroutines.** + +# Keep Kotlin metadata +-keep class kotlin.Metadata { *; } + +# ============================================================ +# Room Database +# ============================================================ +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * +-keepclassmembers class * { + @androidx.room.* ; +} +-keep class com.rosetta.messenger.database.** { *; } + +# ============================================================ +# Crypto & Security +# ============================================================ +# BouncyCastle +-keep class org.bouncycastle.** { *; } +-dontwarn org.bouncycastle.** + +# Tink (XChaCha20-Poly1305) +-keep class com.google.crypto.tink.** { *; } +-dontwarn com.google.crypto.tink.** + +# BitcoinJ +-keep class org.bitcoinj.** { *; } +-dontwarn org.bitcoinj.** + +# Security Crypto +-keep class androidx.security.crypto.** { *; } +-dontwarn androidx.security.crypto.** + +# Keep crypto classes +-keep class com.rosetta.messenger.crypto.** { *; } + +# ============================================================ +# Compose +# ============================================================ +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** + +# Keep @Composable functions +-keepclassmembers class * { + @androidx.compose.runtime.Composable ; +} + +# ============================================================ +# Data Models +# ============================================================ +# Keep all data classes +-keep class com.rosetta.messenger.data.** { *; } +-keep class com.rosetta.messenger.network.** { *; } + +# Keep Parcelable +-keepclassmembers class * implements android.os.Parcelable { + public static final android.os.Parcelable$Creator *; +} + +# ============================================================ +# Serialization +# ============================================================ +# Keep serialization info +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses +-keepattributes EnclosingMethod + +# Gson +-keep class com.google.gson.** { *; } +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# ============================================================ +# AndroidX & Material +# ============================================================ +-keep class com.google.android.material.** { *; } +-dontwarn com.google.android.material.** + +-keep class androidx.** { *; } +-dontwarn androidx.** + +# ============================================================ +# Reflection & Native +# ============================================================ +-keepattributes RuntimeVisibleAnnotations +-keepattributes RuntimeInvisibleAnnotations +-keepattributes RuntimeVisibleParameterAnnotations +-keepattributes RuntimeInvisibleParameterAnnotations + +# Keep native methods +-keepclasseswithmembernames class * { + native ; +} + +# ============================================================ +# Debugging (remove in production) +# ============================================================ +-keepattributes SourceFile,LineNumberTable +-renamesourcefileattribute SourceFile + +# ============================================================ +# WebSocket (if using OkHttp) +# ============================================================ +-dontwarn okhttp3.** +-dontwarn okio.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } + +# ============================================================ +# Lottie Animations +# ============================================================ +-keep class com.airbnb.lottie.** { *; } +-dontwarn com.airbnb.lottie.** + +# ============================================================ +# Coil Image Loading +# ============================================================ +-keep class coil.** { *; } +-dontwarn coil.** diff --git a/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt b/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt index ef0595b..ffa12af 100644 --- a/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt +++ b/app/src/main/java/app/rosette/android/ui/keyboard/KeyboardTransitionCoordinator.kt @@ -13,9 +13,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** - * Координатор переходов между системной клавиатурой и emoji панелью. - * Реализует Telegram-style плавные анимации. - * + * Координатор переходов между системной клавиатурой и emoji панелью. Реализует Telegram-style + * плавные анимации. + * * Ключевые принципы: * - 250ms duration (как в Telegram AdjustPanLayoutHelper) * - Немедленное резервирование места @@ -23,249 +23,252 @@ import androidx.compose.ui.unit.dp * - Синхронизация всех переходов */ class KeyboardTransitionCoordinator { - + companion object { const val TRANSITION_DURATION = 250L const val SHORT_DELAY = 50L private const val TAG = "KeyboardTransition" } - + // ============ Состояния переходов ============ - + enum class TransitionState { - IDLE, // Ничего не происходит - KEYBOARD_TO_EMOJI, // Keyboard → Emoji - EMOJI_TO_KEYBOARD, // Emoji → Keyboard - KEYBOARD_OPENING, // Только keyboard открывается - EMOJI_OPENING, // Только emoji открывается - KEYBOARD_CLOSING, // Только keyboard закрывается - EMOJI_CLOSING // Только emoji закрывается + IDLE, // Ничего не происходит + KEYBOARD_TO_EMOJI, // Keyboard → Emoji + EMOJI_TO_KEYBOARD, // Emoji → Keyboard + KEYBOARD_OPENING, // Только keyboard открывается + EMOJI_OPENING, // Только emoji открывается + KEYBOARD_CLOSING, // Только keyboard закрывается + EMOJI_CLOSING // Только emoji закрывается } - + // ============ Основное состояние ============ - + var currentState by mutableStateOf(TransitionState.IDLE) private set - + var transitionProgress by mutableFloatStateOf(0f) private set - + // ============ Высоты ============ - + var keyboardHeight by mutableStateOf(0.dp) var emojiHeight by mutableStateOf(0.dp) - + // 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji private var maxKeyboardHeight by mutableStateOf(0.dp) - + // ============ Флаги видимости ============ - + var isKeyboardVisible by mutableStateOf(false) var isEmojiVisible by mutableStateOf(false) var isTransitioning by mutableStateOf(false) private set - + // 🔥 Показывается ли сейчас Box с эмодзи (включая анимацию fade-out) // Используется для отключения imePadding пока Box виден var isEmojiBoxVisible by mutableStateOf(false) - + // 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры) private var pendingShowEmojiCallback: (() -> Unit)? = null - + // 📊 Для умного логирования (не каждый фрейм) private var lastLogTime = 0L private var lastLoggedHeight = -1f /** * Переход от системной клавиатуры к emoji панели. - * - * 🔥 КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Показываем emoji СРАЗУ! - * Не ждем закрытия клавиатуры - emoji начинает выезжать синхронно. + * + * 🔥 КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Показываем emoji СРАЗУ! Не ждем закрытия клавиатуры - emoji начинает + * выезжать синхронно. */ - fun requestShowEmoji( - hideKeyboard: () -> Unit, - showEmoji: () -> Unit - ) { + fun requestShowEmoji(hideKeyboard: () -> Unit, showEmoji: () -> Unit) { currentState = TransitionState.KEYBOARD_TO_EMOJI isTransitioning = true - + // 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры) if (maxKeyboardHeight > 0.dp) { emojiHeight = maxKeyboardHeight } - + // 🔥 ПОКАЗЫВАЕМ EMOJI СРАЗУ! Не ждем закрытия клавиатуры showEmoji() - + isEmojiVisible = true // 🔥 ВАЖНО: Устанавливаем флаг видимости emoji! + // Теперь скрываем клавиатуру (она будет закрываться синхронно с появлением emoji) Log.d(TAG, " ⌨️ Hiding keyboard...") try { hideKeyboard() Log.d(TAG, " ✅ hideKeyboard() completed") - } catch (e: Exception) { - } - + } catch (e: Exception) {} + isKeyboardVisible = false currentState = TransitionState.IDLE isTransitioning = false - + // Очищаем pending callback - больше не нужен pendingShowEmojiCallback = null } - + // ============ Главный метод: Emoji → Keyboard ============ - + /** - * Переход от emoji панели к системной клавиатуре. - * Telegram паттерн: показать клавиатуру и плавно скрыть emoji. + * Переход от emoji панели к системной клавиатуре. Telegram паттерн: показать клавиатуру и + * плавно скрыть emoji. */ - fun requestShowKeyboard( - showKeyboard: () -> Unit, - hideEmoji: () -> Unit - ) { + fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) { // 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт) if (pendingShowEmojiCallback != null) { pendingShowEmojiCallback = null } - + currentState = TransitionState.EMOJI_TO_KEYBOARD isTransitioning = true - + // Шаг 1: Показать системную клавиатуру try { showKeyboard() - } catch (e: Exception) { - } - + } catch (e: Exception) {} + // Шаг 2: Через небольшую задержку скрыть emoji - Handler(Looper.getMainLooper()).postDelayed({ - try { - hideEmoji() - isEmojiVisible = false - isKeyboardVisible = true - - // Через время анимации завершаем переход - Handler(Looper.getMainLooper()).postDelayed({ - currentState = TransitionState.IDLE - isTransitioning = false - }, TRANSITION_DURATION) - } catch (e: Exception) { - currentState = TransitionState.IDLE - isTransitioning = false - } - }, SHORT_DELAY) + Handler(Looper.getMainLooper()) + .postDelayed( + { + try { + hideEmoji() + isEmojiVisible = false + isKeyboardVisible = true + + // Через время анимации завершаем переход + Handler(Looper.getMainLooper()) + .postDelayed( + { + currentState = TransitionState.IDLE + isTransitioning = false + }, + TRANSITION_DURATION + ) + } catch (e: Exception) { + currentState = TransitionState.IDLE + isTransitioning = false + } + }, + SHORT_DELAY + ) } - + // ============ Простые переходы ============ - - /** - * Открыть только emoji панель (без клавиатуры). - */ + + /** Открыть только emoji панель (без клавиатуры). */ fun openEmojiOnly(showEmoji: () -> Unit) { currentState = TransitionState.EMOJI_OPENING isTransitioning = true - + // Установить высоту emoji равной сохраненной высоте клавиатуры if (emojiHeight == 0.dp && keyboardHeight > 0.dp) { emojiHeight = keyboardHeight } - + showEmoji() isEmojiVisible = true - - Handler(Looper.getMainLooper()).postDelayed({ - currentState = TransitionState.IDLE - isTransitioning = false - }, TRANSITION_DURATION) + + Handler(Looper.getMainLooper()) + .postDelayed( + { + currentState = TransitionState.IDLE + isTransitioning = false + }, + TRANSITION_DURATION + ) } - - /** - * Закрыть emoji панель. - */ + + /** Закрыть emoji панель. */ fun closeEmoji(hideEmoji: () -> Unit) { currentState = TransitionState.EMOJI_CLOSING isTransitioning = true - + hideEmoji() isEmojiVisible = false - - Handler(Looper.getMainLooper()).postDelayed({ - currentState = TransitionState.IDLE - isTransitioning = false - }, TRANSITION_DURATION) + + Handler(Looper.getMainLooper()) + .postDelayed( + { + currentState = TransitionState.IDLE + isTransitioning = false + }, + TRANSITION_DURATION + ) } - - /** - * Закрыть системную клавиатуру. - */ + + /** Закрыть системную клавиатуру. */ fun closeKeyboard(hideKeyboard: () -> Unit) { currentState = TransitionState.KEYBOARD_CLOSING isTransitioning = true - + hideKeyboard() isKeyboardVisible = false - - Handler(Looper.getMainLooper()).postDelayed({ - currentState = TransitionState.IDLE - isTransitioning = false - }, TRANSITION_DURATION) + + Handler(Looper.getMainLooper()) + .postDelayed( + { + currentState = TransitionState.IDLE + isTransitioning = false + }, + TRANSITION_DURATION + ) } - + // ============ Вспомогательные методы ============ - - /** - * Обновить высоту клавиатуры из IME. - */ + + /** Обновить высоту клавиатуры из IME. */ fun updateKeyboardHeight(height: Dp) { val now = System.currentTimeMillis() val heightChanged = kotlin.math.abs(height.value - lastLoggedHeight) > 5f - + // Логируем раз в 50ms ИЛИ при значительном изменении высоты (>5dp) if (heightChanged && (now - lastLogTime > 50 || lastLoggedHeight < 0)) { lastLogTime = now lastLoggedHeight = height.value } - + if (height > 100.dp && height != keyboardHeight) { keyboardHeight = height - + // 🔥 Сохраняем максимальную высоту if (height > maxKeyboardHeight) { maxKeyboardHeight = height } - + // Если emoji высота не установлена, синхронизировать if (emojiHeight == 0.dp) { emojiHeight = height } } else if (height == 0.dp && keyboardHeight != 0.dp) { // 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты - + // Восстанавливаем emojiHeight до максимальной высоты if (maxKeyboardHeight > 0.dp) { emojiHeight = maxKeyboardHeight } - + // Обнуляем keyboardHeight keyboardHeight = 0.dp } - + // 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji() } - - /** - * Обновить высоту emoji панели. - */ + + /** Обновить высоту emoji панели. */ fun updateEmojiHeight(height: Dp) { if (height > 0.dp && height != emojiHeight) { emojiHeight = height } } - + /** * Синхронизировать высоты (emoji = keyboard). - * - * 🔥 ВАЖНО: Синхронизируем ТОЛЬКО когда клавиатура ОТКРЫВАЕТСЯ! - * При закрытии клавиатуры emojiHeight должна оставаться фиксированной! + * + * 🔥 ВАЖНО: Синхронизируем ТОЛЬКО когда клавиатура ОТКРЫВАЕТСЯ! При закрытии клавиатуры + * emojiHeight должна оставаться фиксированной! */ fun syncHeights() { // 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji @@ -273,10 +276,10 @@ class KeyboardTransitionCoordinator { emojiHeight = keyboardHeight } } - + /** - * Инициализация высоты emoji панели (для pre-rendered подхода). - * Должна быть вызвана при старте для избежания 0dp высоты. + * Инициализация высоты emoji панели (для pre-rendered подхода). Должна быть вызвана при старте + * для избежания 0dp высоты. */ fun initializeEmojiHeight(height: Dp) { if (emojiHeight == 0.dp && height > 0.dp) { @@ -284,10 +287,10 @@ class KeyboardTransitionCoordinator { maxKeyboardHeight = height } } - + /** - * Получить текущую высоту для резервирования места. - * Telegram паттерн: всегда резервировать максимум из двух. + * Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать + * максимум из двух. */ fun getReservedHeight(): Dp { return when { @@ -297,17 +300,13 @@ class KeyboardTransitionCoordinator { else -> 0.dp } } - - /** - * Проверка, можно ли начать новый переход. - */ + + /** Проверка, можно ли начать новый переход. */ fun canStartTransition(): Boolean { return !isTransitioning } - - /** - * Сброс состояния (для отладки). - */ + + /** Сброс состояния (для отладки). */ fun reset() { currentState = TransitionState.IDLE isTransitioning = false @@ -315,17 +314,12 @@ class KeyboardTransitionCoordinator { isEmojiVisible = false transitionProgress = 0f } - - /** - * Логирование текущего состояния. - */ - fun logState() { - } + + /** Логирование текущего состояния. */ + fun logState() {} } -/** - * Composable для создания и запоминания coordinator'а. - */ +/** Composable для создания и запоминания coordinator'а. */ @Composable fun rememberKeyboardTransitionCoordinator(): KeyboardTransitionCoordinator { return remember { KeyboardTransitionCoordinator() } diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 0f972ca..3adf353 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -350,7 +350,7 @@ fun MainScreen( onToggleTheme: () -> Unit = {}, onLogout: () -> Unit = {} ) { - val accountName = account?.publicKey ?: "04c266b98ae5" + val accountName = account?.name ?: "Account" val accountPhone = account?.publicKey?.take(16)?.let { "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" } ?: "+7 775 9932587" diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index b7d936f..a878767 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.* import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* @@ -35,21 +34,38 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun SetPasswordScreen( - seedPhrase: List, - isDarkTheme: Boolean, - onBack: () -> Unit, - onAccountCreated: (DecryptedAccount) -> Unit + seedPhrase: List, + isDarkTheme: Boolean, + onBack: () -> Unit, + onAccountCreated: (DecryptedAccount) -> Unit ) { - val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) - val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) - val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) - val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec) - val cardColor by animateColorAsState(if (isDarkTheme) AuthSurface else AuthSurfaceLight, animationSpec = themeAnimSpec) - + val themeAnimSpec = + tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) + val backgroundColor by + animateColorAsState( + if (isDarkTheme) AuthBackground else AuthBackgroundLight, + animationSpec = themeAnimSpec + ) + val textColor by + animateColorAsState( + if (isDarkTheme) Color.White else Color.Black, + animationSpec = themeAnimSpec + ) + val secondaryTextColor by + animateColorAsState( + if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), + animationSpec = themeAnimSpec + ) + val cardColor by + animateColorAsState( + if (isDarkTheme) AuthSurface else AuthSurfaceLight, + animationSpec = themeAnimSpec + ) + val context = LocalContext.current val accountManager = remember { AccountManager(context) } val scope = rememberCoroutineScope() - + var password by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") } var passwordVisible by remember { mutableStateOf(false) } @@ -57,492 +73,540 @@ fun SetPasswordScreen( var isCreating by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var visible by remember { mutableStateOf(false) } - + // Track keyboard visibility val view = androidx.compose.ui.platform.LocalView.current var isKeyboardVisible by remember { mutableStateOf(false) } - + DisposableEffect(view) { - val listener = android.view.ViewTreeObserver.OnGlobalLayoutListener { - val rect = android.graphics.Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - isKeyboardVisible = keypadHeight > screenHeight * 0.15 - } + val listener = + android.view.ViewTreeObserver.OnGlobalLayoutListener { + val rect = android.graphics.Rect() + view.getWindowVisibleDisplayFrame(rect) + val screenHeight = view.rootView.height + val keypadHeight = screenHeight - rect.bottom + isKeyboardVisible = keypadHeight > screenHeight * 0.15 + } view.viewTreeObserver.addOnGlobalLayoutListener(listener) - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(listener) - } + onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) } } - - LaunchedEffect(Unit) { - visible = true - } - + + LaunchedEffect(Unit) { visible = true } + val passwordsMatch = password == confirmPassword && password.isNotEmpty() val isPasswordWeak = password.isNotEmpty() && password.length < 6 val canContinue = passwordsMatch && !isCreating - - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - ) { - Column( - modifier = Modifier - .fillMaxSize() - .statusBarsPadding() - ) { + + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) { // Top Bar Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onBack, enabled = !isCreating) { Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", - tint = textColor.copy(alpha = 0.6f) + imageVector = Icons.Default.ArrowBack, + contentDescription = "Back", + tint = textColor.copy(alpha = 0.6f) ) } Spacer(modifier = Modifier.weight(1f)) Text( - text = "Set Password", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = textColor + text = "Set Password", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = textColor ) Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.width(48.dp)) } - + Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier.fillMaxSize() + .imePadding() + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp)) - + // Lock Icon - smaller when keyboard is visible - val iconSize by animateDpAsState( - targetValue = if (isKeyboardVisible) 48.dp else 80.dp, - animationSpec = tween(300, easing = FastOutSlowInEasing) - ) - val iconInnerSize by animateDpAsState( - targetValue = if (isKeyboardVisible) 24.dp else 40.dp, - animationSpec = tween(300, easing = FastOutSlowInEasing) - ) - + val iconSize by + animateDpAsState( + targetValue = if (isKeyboardVisible) 48.dp else 80.dp, + animationSpec = tween(300, easing = FastOutSlowInEasing) + ) + val iconInnerSize by + animateDpAsState( + targetValue = if (isKeyboardVisible) 24.dp else 40.dp, + animationSpec = tween(300, easing = FastOutSlowInEasing) + ) + AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500)) + scaleIn( - initialScale = 0.5f, - animationSpec = tween(500, easing = FastOutSlowInEasing) - ) + visible = visible, + enter = + fadeIn(tween(500)) + + scaleIn( + initialScale = 0.5f, + animationSpec = + tween(500, easing = FastOutSlowInEasing) + ) ) { Box( - modifier = Modifier - .size(iconSize) - .clip(RoundedCornerShape(if (isKeyboardVisible) 12.dp else 20.dp)) - .background(PrimaryBlue.copy(alpha = 0.1f)), - contentAlignment = Alignment.Center + modifier = + Modifier.size(iconSize) + .clip( + RoundedCornerShape( + if (isKeyboardVisible) 12.dp else 20.dp + ) + ) + .background(PrimaryBlue.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.Lock, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(iconInnerSize) + Icons.Default.Lock, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(iconInnerSize) ) } } - + Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp)) - + AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( - initialOffsetY = { -20 }, - animationSpec = tween(500, delayMillis = 100) - ) + visible = visible, + enter = + fadeIn(tween(500, delayMillis = 100)) + + slideInVertically( + initialOffsetY = { -20 }, + animationSpec = tween(500, delayMillis = 100) + ) ) { Text( - text = "Protect Your Account", - fontSize = if (isKeyboardVisible) 20.sp else 24.sp, - fontWeight = FontWeight.Bold, - color = textColor + text = "Protect Your Account", + fontSize = if (isKeyboardVisible) 20.sp else 24.sp, + fontWeight = FontWeight.Bold, + color = textColor ) } - + Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp)) - + AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 200)) + visible = visible, + enter = fadeIn(tween(500, delayMillis = 200)) ) { Text( - text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.", - fontSize = if (isKeyboardVisible) 12.sp else 14.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center, - lineHeight = if (isKeyboardVisible) 16.sp else 20.sp + text = + "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.", + fontSize = if (isKeyboardVisible) 12.sp else 14.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center, + lineHeight = if (isKeyboardVisible) 16.sp else 20.sp ) } - + Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp)) - + // Password Field AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 300)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 300) - ) + visible = visible, + enter = + fadeIn(tween(500, delayMillis = 300)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(500, delayMillis = 300) + ) ) { OutlinedTextField( - value = password, - onValueChange = { - password = it - error = null - }, - label = { Text("Password") }, - placeholder = { Text("Enter password") }, - singleLine = true, - visualTransformation = if (passwordVisible) - VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) - Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (passwordVisible) "Hide" else "Show" - ) - } - }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), - focusedLabelColor = PrimaryBlue, - cursorColor = PrimaryBlue, - focusedTextColor = textColor, - unfocusedTextColor = textColor - ), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Next + value = password, + onValueChange = { + password = it + error = null + }, + label = { Text("Password") }, + placeholder = { Text("Enter password") }, + singleLine = true, + visualTransformation = + if (passwordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) Icons.Default.VisibilityOff + else Icons.Default.Visibility, + contentDescription = + if (passwordVisible) "Hide" else "Show" + ) + } + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryBlue, + unfocusedBorderColor = + if (isDarkTheme) Color(0xFF4A4A4A) + else Color(0xFFD0D0D0), + focusedLabelColor = PrimaryBlue, + cursorColor = PrimaryBlue, + focusedTextColor = textColor, + unfocusedTextColor = textColor + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next + ) ) - ) } - + // Password strength indicator if (password.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(400, delayMillis = 350)) + slideInHorizontally( - initialOffsetX = { -30 }, - animationSpec = tween(400, delayMillis = 350) - ) + visible = visible, + enter = + fadeIn(tween(400, delayMillis = 350)) + + slideInHorizontally( + initialOffsetX = { -30 }, + animationSpec = tween(400, delayMillis = 350) + ) ) { Column(modifier = Modifier.fillMaxWidth()) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - val strength = when { - password.length < 6 -> "Weak" - password.length < 10 -> "Medium" - else -> "Strong" - } - val strengthColor = when { - password.length < 6 -> Color(0xFFE53935) - password.length < 10 -> Color(0xFFFFA726) - else -> Color(0xFF4CAF50) - } + val strength = + when { + password.length < 6 -> "Weak" + password.length < 10 -> "Medium" + else -> "Strong" + } + val strengthColor = + when { + password.length < 6 -> Color(0xFFE53935) + password.length < 10 -> Color(0xFFFFA726) + else -> Color(0xFF4CAF50) + } Icon( - imageVector = Icons.Default.Shield, - contentDescription = null, - tint = strengthColor, - modifier = Modifier.size(16.dp) + imageVector = Icons.Default.Shield, + contentDescription = null, + tint = strengthColor, + modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "Password strength: $strength", - fontSize = 12.sp, - color = strengthColor + text = "Password strength: $strength", + fontSize = 12.sp, + color = strengthColor ) } // Warning for weak passwords if (isPasswordWeak) { Spacer(modifier = Modifier.height(4.dp)) Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(Color(0xFFE53935).copy(alpha = 0.1f)) - .padding(8.dp), - verticalAlignment = Alignment.Top + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + Color(0xFFE53935).copy(alpha = 0.1f) + ) + .padding(8.dp), + verticalAlignment = Alignment.Top ) { Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = Color(0xFFE53935), - modifier = Modifier.size(16.dp) + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Your password is too weak. Consider using at least 6 characters for better security.", - fontSize = 11.sp, - color = Color(0xFFE53935), - lineHeight = 14.sp + text = + "Your password is too weak. Consider using at least 6 characters for better security.", + fontSize = 11.sp, + color = Color(0xFFE53935), + lineHeight = 14.sp ) } } } } } - + Spacer(modifier = Modifier.height(16.dp)) - + // Confirm Password Field AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 400)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 400) - ) + visible = visible, + enter = + fadeIn(tween(500, delayMillis = 400)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(500, delayMillis = 400) + ) ) { OutlinedTextField( - value = confirmPassword, - onValueChange = { - confirmPassword = it - error = null - }, - label = { Text("Confirm Password") }, - placeholder = { Text("Re-enter password") }, - singleLine = true, - visualTransformation = if (confirmPasswordVisible) - VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) { - Icon( - imageVector = if (confirmPasswordVisible) - Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (confirmPasswordVisible) "Hide" else "Show" - ) - } - }, - isError = confirmPassword.isNotEmpty() && !passwordsMatch, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), - focusedLabelColor = PrimaryBlue, - cursorColor = PrimaryBlue, - focusedTextColor = textColor, - unfocusedTextColor = textColor - ), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done + value = confirmPassword, + onValueChange = { + confirmPassword = it + error = null + }, + label = { Text("Confirm Password") }, + placeholder = { Text("Re-enter password") }, + singleLine = true, + visualTransformation = + if (confirmPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton( + onClick = { + confirmPasswordVisible = !confirmPasswordVisible + } + ) { + Icon( + imageVector = + if (confirmPasswordVisible) + Icons.Default.VisibilityOff + else Icons.Default.Visibility, + contentDescription = + if (confirmPasswordVisible) "Hide" else "Show" + ) + } + }, + isError = confirmPassword.isNotEmpty() && !passwordsMatch, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryBlue, + unfocusedBorderColor = + if (isDarkTheme) Color(0xFF4A4A4A) + else Color(0xFFD0D0D0), + focusedLabelColor = PrimaryBlue, + cursorColor = PrimaryBlue, + focusedTextColor = textColor, + unfocusedTextColor = textColor + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ) ) - ) } - + // Match indicator if (confirmPassword.isNotEmpty()) { Spacer(modifier = Modifier.height(8.dp)) AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(400, delayMillis = 450)) + slideInHorizontally( - initialOffsetX = { -30 }, - animationSpec = tween(400, delayMillis = 450) - ) + visible = visible, + enter = + fadeIn(tween(400, delayMillis = 450)) + + slideInHorizontally( + initialOffsetX = { -30 }, + animationSpec = tween(400, delayMillis = 450) + ) ) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - val matchIcon = if (passwordsMatch) Icons.Default.Check else Icons.Default.Close - val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935) - val matchText = if (passwordsMatch) "Passwords match" else "Passwords don't match" - + val matchIcon = + if (passwordsMatch) Icons.Default.Check else Icons.Default.Close + val matchColor = + if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935) + val matchText = + if (passwordsMatch) "Passwords match" + else "Passwords don't match" + Icon( - imageVector = matchIcon, - contentDescription = null, - tint = matchColor, - modifier = Modifier.size(16.dp) + imageVector = matchIcon, + contentDescription = null, + tint = matchColor, + modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) - Text( - text = matchText, - fontSize = 12.sp, - color = matchColor - ) - } + Text(text = matchText, fontSize = 12.sp, color = matchColor) + } } } - + // Error message error?.let { errorMsg -> Spacer(modifier = Modifier.height(16.dp)) AnimatedVisibility( - visible = true, - enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f) + visible = true, + enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f) ) { Text( - text = errorMsg, - fontSize = 14.sp, - color = Color(0xFFE53935), - textAlign = TextAlign.Center + text = errorMsg, + fontSize = 14.sp, + color = Color(0xFFE53935), + textAlign = TextAlign.Center ) } } - + Spacer(modifier = Modifier.weight(1f)) - + // Info - hide when keyboard is visible AnimatedVisibility( - visible = visible && !isKeyboardVisible, - enter = fadeIn(tween(400)) + slideInVertically( - initialOffsetY = { 30 }, - animationSpec = tween(400) - ) + scaleIn( - initialScale = 0.9f, - animationSpec = tween(400) - ), - exit = fadeOut(tween(300)) + slideOutVertically( - targetOffsetY = { 30 }, - animationSpec = tween(300) - ) + scaleOut( - targetScale = 0.9f, - animationSpec = tween(300) - ) + visible = visible && !isKeyboardVisible, + enter = + fadeIn(tween(400)) + + slideInVertically( + initialOffsetY = { 30 }, + animationSpec = tween(400) + ) + + scaleIn(initialScale = 0.9f, animationSpec = tween(400)), + exit = + fadeOut(tween(300)) + + slideOutVertically( + targetOffsetY = { 30 }, + animationSpec = tween(300) + ) + + scaleOut(targetScale = 0.9f, animationSpec = tween(300)) ) { Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(cardColor) - .padding(16.dp), - verticalAlignment = Alignment.Top + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(cardColor) + .padding(16.dp), + verticalAlignment = Alignment.Top ) { Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(20.dp) + imageVector = Icons.Default.Info, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = "Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.", - fontSize = 13.sp, - color = secondaryTextColor, - lineHeight = 18.sp + text = + "Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.", + fontSize = 13.sp, + color = secondaryTextColor, + lineHeight = 18.sp ) } } - + Spacer(modifier = Modifier.height(16.dp)) - + // Create Account Button AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(500, delayMillis = 600) - ) + visible = visible, + enter = + fadeIn(tween(500, delayMillis = 600)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(500, delayMillis = 600) + ) ) { Button( - onClick = { - if (!passwordsMatch) { - error = "Passwords don't match" - return@Button + onClick = { + if (!passwordsMatch) { + error = "Passwords don't match" + return@Button + } + + isCreating = true + scope.launch { + try { + // Generate keys from seed phrase + val keyPair = + CryptoManager.generateKeyPairFromSeed(seedPhrase) + + // Encrypt private key and seed phrase + val encryptedPrivateKey = + CryptoManager.encryptWithPassword( + keyPair.privateKey, + password + ) + val encryptedSeedPhrase = + CryptoManager.encryptWithPassword( + seedPhrase.joinToString(" "), + password + ) + + // Save account with truncated public key as name + val truncatedKey = + "${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}" + val account = + EncryptedAccount( + publicKey = keyPair.publicKey, + encryptedPrivateKey = encryptedPrivateKey, + encryptedSeedPhrase = encryptedSeedPhrase, + name = truncatedKey + ) + + accountManager.saveAccount(account) + accountManager.setCurrentAccount(keyPair.publicKey) + + // 🔌 Connect to server and authenticate + val privateKeyHash = + CryptoManager.generatePrivateKeyHash( + keyPair.privateKey + ) + ProtocolManager.connect() + // Give WebSocket time to connect before authenticating + kotlinx.coroutines.delay(500) + ProtocolManager.authenticate( + keyPair.publicKey, + privateKeyHash + ) + + // Create DecryptedAccount to pass to callback + val decryptedAccount = + DecryptedAccount( + publicKey = keyPair.publicKey, + privateKey = keyPair.privateKey, + seedPhrase = seedPhrase, + privateKeyHash = privateKeyHash, + name = truncatedKey + ) + + onAccountCreated(decryptedAccount) + } catch (e: Exception) { + error = "Failed to create account: ${e.message}" + isCreating = false + } + } + }, + enabled = canContinue, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White, + disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), + disabledContentColor = Color.White.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(12.dp) + ) { + if (isCreating) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp + ) + } else { + Text( + text = "Create Account", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold + ) } - - isCreating = true - scope.launch { - try { - // Generate keys from seed phrase - val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase) - - // Encrypt private key and seed phrase - val encryptedPrivateKey = CryptoManager.encryptWithPassword( - keyPair.privateKey, password - ) - val encryptedSeedPhrase = CryptoManager.encryptWithPassword( - seedPhrase.joinToString(" "), password - ) - - // Save account with truncated public key as name - val truncatedKey = "${keyPair.publicKey.take(6)}...${keyPair.publicKey.takeLast(4)}" - val account = EncryptedAccount( - publicKey = keyPair.publicKey, - encryptedPrivateKey = encryptedPrivateKey, - encryptedSeedPhrase = encryptedSeedPhrase, - name = truncatedKey - ) - - accountManager.saveAccount(account) - accountManager.setCurrentAccount(keyPair.publicKey) - - // 🔌 Connect to server and authenticate - val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey) - ProtocolManager.connect() - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) - ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash) - - // Create DecryptedAccount to pass to callback - val decryptedAccount = DecryptedAccount( - publicKey = keyPair.publicKey, - privateKey = keyPair.privateKey, - seedPhrase = seedPhrase, - privateKeyHash = privateKeyHash, - name = truncatedKey - ) - - onAccountCreated(decryptedAccount) - } catch (e: Exception) { - error = "Failed to create account: ${e.message}" - isCreating = false - } - } - }, - enabled = canContinue, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue, - contentColor = Color.White, - disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), - disabledContentColor = Color.White.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(12.dp) - ) { - if (isCreating) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White, - strokeWidth = 2.dp - ) - } else { - Text( - text = "Create Account", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) } - } } + } Spacer(modifier = Modifier.height(32.dp)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 3ae3c19..15f1dbd 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -38,102 +38,120 @@ import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.network.ProtocolManager -import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarText -import kotlinx.coroutines.flow.first +import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.launch // Account model for dropdown data class AccountItem( - val publicKey: String, - val name: String, - val encryptedAccount: EncryptedAccount + val publicKey: String, + val name: String, + val encryptedAccount: EncryptedAccount ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun UnlockScreen( - isDarkTheme: Boolean, - selectedAccountId: String? = null, - onUnlocked: (DecryptedAccount) -> Unit, - onSwitchAccount: () -> Unit = {} + isDarkTheme: Boolean, + selectedAccountId: String? = null, + onUnlocked: (DecryptedAccount) -> Unit, + onSwitchAccount: () -> Unit = {} ) { - val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) - val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) - val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) - val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec) - val cardBackground by animateColorAsState(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5), animationSpec = themeAnimSpec) - + val themeAnimSpec = + tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) + val backgroundColor by + animateColorAsState( + if (isDarkTheme) AuthBackground else AuthBackgroundLight, + animationSpec = themeAnimSpec + ) + val textColor by + animateColorAsState( + if (isDarkTheme) Color.White else Color.Black, + animationSpec = themeAnimSpec + ) + val secondaryTextColor by + animateColorAsState( + if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), + animationSpec = themeAnimSpec + ) + val cardBackground by + animateColorAsState( + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5), + animationSpec = themeAnimSpec + ) + val context = LocalContext.current val accountManager = remember { AccountManager(context) } val scope = rememberCoroutineScope() - + var password by remember { mutableStateOf("") } var passwordVisible by remember { mutableStateOf(false) } var isUnlocking by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } - + // Account selection state var accounts by remember { mutableStateOf>(emptyList()) } var selectedAccount by remember { mutableStateOf(null) } var isDropdownExpanded by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } val searchFocusRequester = remember { FocusRequester() } - + // Load accounts and pre-select last used account LaunchedEffect(Unit) { // ⚡ СИНХРОННОЕ чтение последнего аккаунта ДО загрузки списка val lastLoggedKey = accountManager.getLastLoggedPublicKey() - + val allAccounts = accountManager.getAllAccounts() - accounts = allAccounts.map { acc -> - AccountItem( - publicKey = acc.publicKey, - name = acc.name, - encryptedAccount = acc - ) - } - + accounts = + allAccounts.map { acc -> + AccountItem(publicKey = acc.publicKey, name = acc.name, encryptedAccount = acc) + } + // Find the target account - приоритет: selectedAccountId > lastLoggedKey > первый - val targetAccount = when { - !selectedAccountId.isNullOrEmpty() -> { - accounts.find { it.publicKey == selectedAccountId } - } - !lastLoggedKey.isNullOrEmpty() -> { - accounts.find { it.publicKey == lastLoggedKey } - } - else -> { - accounts.firstOrNull() - } - } - + val targetAccount = + when { + !selectedAccountId.isNullOrEmpty() -> { + accounts.find { it.publicKey == selectedAccountId } + } + !lastLoggedKey.isNullOrEmpty() -> { + accounts.find { it.publicKey == lastLoggedKey } + } + else -> { + accounts.firstOrNull() + } + } + selectedAccount = targetAccount ?: accounts.firstOrNull() } - + // Filter accounts by search - val filteredAccounts = remember(searchQuery, accounts) { - if (searchQuery.isEmpty()) accounts - else accounts.filter { - it.name.contains(searchQuery, ignoreCase = true) || - it.publicKey.contains(searchQuery, ignoreCase = true) - } - } - + val filteredAccounts = + remember(searchQuery, accounts) { + if (searchQuery.isEmpty()) accounts + else + accounts.filter { + it.name.contains(searchQuery, ignoreCase = true) || + it.publicKey.contains(searchQuery, ignoreCase = true) + } + } + // Entry animation var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { visible = true } - + // Dropdown animation - val dropdownProgress by animateFloatAsState( - targetValue = if (isDropdownExpanded) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "dropdownProgress" - ) - + val dropdownProgress by + animateFloatAsState( + targetValue = if (isDropdownExpanded) 1f else 0f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "dropdownProgress" + ) + // Auto-focus search when dropdown opens LaunchedEffect(isDropdownExpanded) { if (isDropdownExpanded) { @@ -147,306 +165,337 @@ fun UnlockScreen( searchQuery = "" } } - - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - ) { + + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .imePadding() - .padding(horizontal = 24.dp) - .statusBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = + Modifier.fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp) + .statusBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.height(40.dp)) - + // Rosetta Logo AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) + visible = visible, + enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) ) { Image( - painter = painterResource(id = R.drawable.rosetta_icon), - contentDescription = "Rosetta", - modifier = Modifier - .size(100.dp) - .clip(CircleShape) + painter = painterResource(id = R.drawable.rosetta_icon), + contentDescription = "Rosetta", + modifier = Modifier.size(100.dp).clip(CircleShape) ) } - + Spacer(modifier = Modifier.height(24.dp)) - + // Title AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 200)) + slideInVertically( - initialOffsetY = { 30 }, - animationSpec = tween(600, delayMillis = 200) - ) + visible = visible, + enter = + fadeIn(tween(600, delayMillis = 200)) + + slideInVertically( + initialOffsetY = { 30 }, + animationSpec = tween(600, delayMillis = 200) + ) ) { Text( - text = "Welcome Back", - fontSize = 28.sp, - fontWeight = FontWeight.Bold, - color = textColor + text = "Welcome Back", + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = textColor ) } - + Spacer(modifier = Modifier.height(8.dp)) - - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 300)) - ) { + + AnimatedVisibility(visible = visible, enter = fadeIn(tween(600, delayMillis = 300))) { Text( - text = "Select your account and enter password", - fontSize = 16.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center + text = "Select your account and enter password", + fontSize = 16.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center ) } - + Spacer(modifier = Modifier.height(32.dp)) - + // Account Selector Card AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 350)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 350) - ) + visible = visible, + enter = + fadeIn(tween(600, delayMillis = 350)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 350) + ) ) { Column { // Account selector dropdown Card( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .clickable(enabled = accounts.size > 1) { - isDropdownExpanded = !isDropdownExpanded - }, - colors = CardDefaults.cardColors(containerColor = cardBackground), - shape = RoundedCornerShape(16.dp) + modifier = + Modifier.fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .clickable(enabled = accounts.size > 1) { + isDropdownExpanded = !isDropdownExpanded + }, + colors = CardDefaults.cardColors(containerColor = cardBackground), + shape = RoundedCornerShape(16.dp) ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically ) { // Avatar if (selectedAccount != null) { - val avatarColors = getAvatarColor(selectedAccount!!.publicKey, isDarkTheme) + val avatarColors = + getAvatarColor(selectedAccount!!.publicKey, isDarkTheme) Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = getAvatarText(selectedAccount!!.publicKey), - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + text = getAvatarText(selectedAccount!!.publicKey), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } } - + Spacer(modifier = Modifier.width(12.dp)) - + // Account info Column(modifier = Modifier.weight(1f)) { Text( - text = selectedAccount?.name ?: "Select Account", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = selectedAccount?.name ?: "Select Account", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) if (selectedAccount != null) { Text( - text = selectedAccount!!.publicKey.take(20) + "...", - fontSize = 13.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = selectedAccount!!.publicKey.take(20) + "...", + fontSize = 13.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } - + // Dropdown arrow with rotation (only show if multiple accounts) if (accounts.size > 1) { Icon( - imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = null, - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier - .size(24.dp) - .graphicsLayer { - rotationZ = 180f * dropdownProgress - } + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = secondaryTextColor.copy(alpha = 0.6f), + modifier = + Modifier.size(24.dp).graphicsLayer { + rotationZ = 180f * dropdownProgress + } ) } } } - + // Dropdown list with animation AnimatedVisibility( - visible = isDropdownExpanded && accounts.size > 1, - enter = fadeIn(tween(150)) + expandVertically( - expandFrom = Alignment.Top, - animationSpec = tween(200, easing = FastOutSlowInEasing) - ), - exit = fadeOut(tween(100)) + shrinkVertically( - shrinkTowards = Alignment.Top, - animationSpec = tween(150) - ) + visible = isDropdownExpanded && accounts.size > 1, + enter = + fadeIn(tween(150)) + + expandVertically( + expandFrom = Alignment.Top, + animationSpec = + tween(200, easing = FastOutSlowInEasing) + ), + exit = + fadeOut(tween(100)) + + shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(150) + ) ) { Card( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .heightIn(max = if (accounts.size > 5) 350.dp else ((accounts.size * 64 + 70).dp)), - colors = CardDefaults.cardColors(containerColor = cardBackground), - shape = RoundedCornerShape(16.dp) + modifier = + Modifier.fillMaxWidth() + .padding(top = 8.dp) + .heightIn( + max = + if (accounts.size > 5) 350.dp + else ((accounts.size * 64 + 70).dp) + ), + colors = CardDefaults.cardColors(containerColor = cardBackground), + shape = RoundedCornerShape(16.dp) ) { Column { // Search field - only show if more than 3 accounts if (accounts.size > 3) { OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - placeholder = { - Text( - "Search accounts...", - color = secondaryTextColor.copy(alpha = 0.6f) - ) - }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = null, - tint = secondaryTextColor.copy(alpha = 0.6f) - ) - }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = Color.Transparent, - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedTextColor = textColor, - unfocusedTextColor = textColor, - cursorColor = PrimaryBlue - ), - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .focusRequester(searchFocusRequester), - shape = RoundedCornerShape(12.dp) + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + "Search accounts...", + color = + secondaryTextColor.copy( + alpha = 0.6f + ) + ) + }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = secondaryTextColor.copy(alpha = 0.6f) + ) + }, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryBlue, + unfocusedBorderColor = + Color.Transparent, + focusedContainerColor = + Color.Transparent, + unfocusedContainerColor = + Color.Transparent, + focusedTextColor = textColor, + unfocusedTextColor = textColor, + cursorColor = PrimaryBlue + ), + singleLine = true, + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = 12.dp, + vertical = 8.dp + ) + .focusRequester(searchFocusRequester), + shape = RoundedCornerShape(12.dp) ) - + Divider( - color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0), - thickness = 0.5.dp + color = + if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE0E0E0), + thickness = 0.5.dp ) } - + // Account list LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = if (accounts.size <= 3) 8.dp else 0.dp) + modifier = + Modifier.fillMaxWidth() + .padding( + vertical = + if (accounts.size <= 3) 8.dp + else 0.dp + ) ) { items(filteredAccounts, key = { it.publicKey }) { account -> - val isSelected = account.publicKey == selectedAccount?.publicKey - val itemScale by animateFloatAsState( - targetValue = if (isSelected) 1f else 0.98f, - label = "itemScale" - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .scale(itemScale) - .clip(RoundedCornerShape(12.dp)) - .clickable { - selectedAccount = account - isDropdownExpanded = false - password = "" - error = null - } - .background( - if (isSelected) PrimaryBlue.copy(alpha = 0.1f) - else Color.Transparent + val isSelected = + account.publicKey == selectedAccount?.publicKey + val itemScale by + animateFloatAsState( + targetValue = if (isSelected) 1f else 0.98f, + label = "itemScale" ) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + + Row( + modifier = + Modifier.fillMaxWidth() + .scale(itemScale) + .clip(RoundedCornerShape(12.dp)) + .clickable { + selectedAccount = account + isDropdownExpanded = false + password = "" + error = null + } + .background( + if (isSelected) + PrimaryBlue.copy( + alpha = 0.1f + ) + else Color.Transparent + ) + .padding( + horizontal = 16.dp, + vertical = 12.dp + ), + verticalAlignment = Alignment.CenterVertically ) { // Avatar - val avatarColors = getAvatarColor(account.publicKey, isDarkTheme) + val avatarColors = + getAvatarColor(account.publicKey, isDarkTheme) Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background( + avatarColors + .backgroundColor + ), + contentAlignment = Alignment.Center ) { Text( - text = getAvatarText(account.publicKey), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + text = getAvatarText(account.publicKey), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } - + Spacer(modifier = Modifier.width(12.dp)) - + Column(modifier = Modifier.weight(1f)) { Text( - text = account.name, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = account.name, + fontSize = 15.sp, + fontWeight = + if (isSelected) FontWeight.SemiBold + else FontWeight.Normal, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) Text( - text = account.publicKey.take(16) + "...", - fontSize = 12.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = account.publicKey.take(16) + "...", + fontSize = 12.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - + if (isSelected) { Icon( - Icons.Default.Check, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(20.dp) + Icons.Default.Check, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) ) } } } - + if (filteredAccounts.isEmpty()) { item { Text( - text = "No accounts found", - color = secondaryTextColor, - fontSize = 14.sp, - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - textAlign = TextAlign.Center + text = "No accounts found", + color = secondaryTextColor, + fontSize = 14.sp, + modifier = + Modifier.fillMaxWidth().padding(24.dp), + textAlign = TextAlign.Center ) } } @@ -456,198 +505,204 @@ fun UnlockScreen( } } } - + Spacer(modifier = Modifier.height(20.dp)) - + // Password Field AnimatedVisibility( - visible = visible && !isDropdownExpanded, - enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 400) - ) + visible = visible && !isDropdownExpanded, + enter = + fadeIn(tween(600, delayMillis = 400)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 400) + ) ) { OutlinedTextField( - value = password, - onValueChange = { - password = it - error = null - }, - label = { Text("Password") }, - placeholder = { Text("Enter your password") }, - singleLine = true, - visualTransformation = if (passwordVisible) - VisualTransformation.None else PasswordVisualTransformation(), - trailingIcon = { - IconButton(onClick = { passwordVisible = !passwordVisible }) { - Icon( - imageVector = if (passwordVisible) - Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (passwordVisible) "Hide" else "Show" - ) - } - }, - isError = error != null, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = PrimaryBlue, - unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), - focusedLabelColor = PrimaryBlue, - cursorColor = PrimaryBlue, - focusedTextColor = textColor, - unfocusedTextColor = textColor, - errorBorderColor = Color(0xFFE53935) - ), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ) + value = password, + onValueChange = { + password = it + error = null + }, + label = { Text("Password") }, + placeholder = { Text("Enter your password") }, + singleLine = true, + visualTransformation = + if (passwordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon( + imageVector = + if (passwordVisible) Icons.Default.VisibilityOff + else Icons.Default.Visibility, + contentDescription = if (passwordVisible) "Hide" else "Show" + ) + } + }, + isError = error != null, + colors = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = PrimaryBlue, + unfocusedBorderColor = + if (isDarkTheme) Color(0xFF4A4A4A) + else Color(0xFFD0D0D0), + focusedLabelColor = PrimaryBlue, + cursorColor = PrimaryBlue, + focusedTextColor = textColor, + unfocusedTextColor = textColor, + errorBorderColor = Color(0xFFE53935) + ), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ) ) } - + // Error message AnimatedVisibility( - visible = error != null, - enter = fadeIn() + slideInVertically { -10 }, - exit = fadeOut() + visible = error != null, + enter = fadeIn() + slideInVertically { -10 }, + exit = fadeOut() ) { Spacer(modifier = Modifier.height(8.dp)) - Text( - text = error ?: "", - fontSize = 14.sp, - color = Color(0xFFE53935) - ) + Text(text = error ?: "", fontSize = 14.sp, color = Color(0xFFE53935)) } - + Spacer(modifier = Modifier.height(24.dp)) - + // Unlock Button AnimatedVisibility( - visible = visible && !isDropdownExpanded, - enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 500) - ) + visible = visible && !isDropdownExpanded, + enter = + fadeIn(tween(600, delayMillis = 500)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 500) + ) ) { Button( - onClick = { - if (selectedAccount == null) { - error = "Please select an account" - return@Button - } - if (password.isEmpty()) { - error = "Please enter your password" - return@Button - } - - isUnlocking = true - scope.launch { - try { - val account = selectedAccount!!.encryptedAccount - - // Try to decrypt - val decryptedPrivateKey = CryptoManager.decryptWithPassword( - account.encryptedPrivateKey, password - ) - - if (decryptedPrivateKey == null) { - error = "Incorrect password" - isUnlocking = false - return@launch - } - - val decryptedSeedPhrase = CryptoManager.decryptWithPassword( - account.encryptedSeedPhrase, password - )?.split(" ") ?: emptyList() - - val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey) - - val decryptedAccount = DecryptedAccount( - publicKey = account.publicKey, - privateKey = decryptedPrivateKey, - seedPhrase = decryptedSeedPhrase, - privateKeyHash = privateKeyHash, - name = account.name - ) - - // Connect to server and authenticate - ProtocolManager.connect() - // Give WebSocket time to connect before authenticating - kotlinx.coroutines.delay(500) - ProtocolManager.authenticate(account.publicKey, privateKeyHash) - - accountManager.setCurrentAccount(account.publicKey) - onUnlocked(decryptedAccount) - - } catch (e: Exception) { - error = "Failed to unlock: \${e.message}" - isUnlocking = false + onClick = { + if (selectedAccount == null) { + error = "Please select an account" + return@Button } - } - }, - enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue, - contentColor = Color.White, - disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), - disabledContentColor = Color.White.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(12.dp) + if (password.isEmpty()) { + error = "Please enter your password" + return@Button + } + + isUnlocking = true + scope.launch { + try { + val account = selectedAccount!!.encryptedAccount + + // Try to decrypt + val decryptedPrivateKey = + CryptoManager.decryptWithPassword( + account.encryptedPrivateKey, + password + ) + + if (decryptedPrivateKey == null) { + error = "Incorrect password" + isUnlocking = false + return@launch + } + + val decryptedSeedPhrase = + CryptoManager.decryptWithPassword( + account.encryptedSeedPhrase, + password + ) + ?.split(" ") + ?: emptyList() + + val privateKeyHash = + CryptoManager.generatePrivateKeyHash( + decryptedPrivateKey + ) + + val decryptedAccount = + DecryptedAccount( + publicKey = account.publicKey, + privateKey = decryptedPrivateKey, + seedPhrase = decryptedSeedPhrase, + privateKeyHash = privateKeyHash, + name = account.name + ) + + // Connect to server and authenticate + ProtocolManager.connect() + // Give WebSocket time to connect before authenticating + kotlinx.coroutines.delay(500) + ProtocolManager.authenticate(account.publicKey, privateKeyHash) + + accountManager.setCurrentAccount(account.publicKey) + onUnlocked(decryptedAccount) + } catch (e: Exception) { + error = "Failed to unlock: \${e.message}" + isUnlocking = false + } + } + }, + enabled = selectedAccount != null && password.isNotEmpty() && !isUnlocking, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White, + disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f), + disabledContentColor = Color.White.copy(alpha = 0.5f) + ), + shape = RoundedCornerShape(12.dp) ) { if (isUnlocking) { CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.White, - strokeWidth = 2.dp + modifier = Modifier.size(24.dp), + color = Color.White, + strokeWidth = 2.dp ) } else { Icon( - imageVector = Icons.Default.LockOpen, - contentDescription = null, - modifier = Modifier.size(20.dp) + imageVector = Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Unlock", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) + Text(text = "Unlock", fontSize = 16.sp, fontWeight = FontWeight.SemiBold) } } } - + Spacer(modifier = Modifier.height(16.dp)) - + // Create New Account button AnimatedVisibility( - visible = visible && !isDropdownExpanded, - enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 600) - ) + visible = visible && !isDropdownExpanded, + enter = + fadeIn(tween(600, delayMillis = 600)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 600) + ) ) { - TextButton( - onClick = onSwitchAccount - ) { + TextButton(onClick = onSwitchAccount) { Icon( - imageVector = Icons.Default.PersonAdd, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(20.dp) + imageVector = Icons.Default.PersonAdd, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Create New Account", - color = PrimaryBlue, - fontSize = 15.sp - ) + Text(text = "Create New Account", color = PrimaryBlue, fontSize = 15.sp) } } - + Spacer(modifier = Modifier.height(60.dp)) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt index b56b6dc..d958f7d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt @@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.auth import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,7 +13,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.graphics.toArgb import androidx.compose.ui.platform.LocalView @@ -24,7 +22,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.airbnb.lottie.compose.* import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import com.rosetta.messenger.ui.onboarding.PrimaryBlue // Auth colors val AuthBackground = Color(0xFF1B1B1B) @@ -34,229 +31,232 @@ val AuthSurfaceLight = Color(0xFFF5F5F5) @Composable fun WelcomeScreen( - isDarkTheme: Boolean, - hasExistingAccount: Boolean = false, - onBack: () -> Unit = {}, - onCreateSeed: () -> Unit, - onImportSeed: () -> Unit + isDarkTheme: Boolean, + hasExistingAccount: Boolean = false, + onBack: () -> Unit = {}, + onCreateSeed: () -> Unit, + onImportSeed: () -> Unit ) { - val themeAnimSpec = tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) - val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) - val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) - val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec) - + val themeAnimSpec = + tween(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) + val backgroundColor by + animateColorAsState( + if (isDarkTheme) AuthBackground else AuthBackgroundLight, + animationSpec = themeAnimSpec + ) + val textColor by + animateColorAsState( + if (isDarkTheme) Color.White else Color.Black, + animationSpec = themeAnimSpec + ) + val secondaryTextColor by + animateColorAsState( + if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), + animationSpec = themeAnimSpec + ) + // Sync navigation bar color with background val view = LocalView.current SideEffect { val window = (view.context as? android.app.Activity)?.window window?.navigationBarColor = backgroundColor.toArgb() } - + // Animation for Lottie - val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) - val lockProgress by animateLottieCompositionAsState( - composition = lockComposition, - iterations = 1, // Play once - speed = 1f - ) - + val lockComposition by + rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) + val lockProgress by + animateLottieCompositionAsState( + composition = lockComposition, + iterations = 1, // Play once + speed = 1f + ) + // Entry animation var visible by remember { mutableStateOf(false) } LaunchedEffect(Unit) { visible = true } - - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - ) { + + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { // Back button when coming from UnlockScreen if (hasExistingAccount) { - IconButton( - onClick = onBack, - modifier = Modifier - .statusBarsPadding() - .padding(4.dp) - ) { + IconButton(onClick = onBack, modifier = Modifier.statusBarsPadding().padding(4.dp)) { Icon( - Icons.Default.ArrowBack, - contentDescription = "Back", - tint = textColor.copy(alpha = 0.6f) + Icons.Default.ArrowBack, + contentDescription = "Back", + tint = textColor.copy(alpha = 0.6f) ) } } - + Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 24.dp) - .statusBarsPadding(), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp).statusBarsPadding(), + horizontalAlignment = Alignment.CenterHorizontally ) { Spacer(modifier = Modifier.weight(0.15f)) - + // Animated Lock Icon AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) + visible = visible, + enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) ) { - Box( - modifier = Modifier.size(180.dp), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) { lockComposition?.let { comp -> LottieAnimation( - composition = comp, - progress = { lockProgress }, - modifier = Modifier.fillMaxSize() + composition = comp, + progress = { lockProgress }, + modifier = Modifier.fillMaxSize() ) } } } - + Spacer(modifier = Modifier.height(32.dp)) - + // Title AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 200)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 200) - ) + visible = visible, + enter = + fadeIn(tween(600, delayMillis = 200)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 200) + ) ) { Text( - text = "Your Keys,\nYour Messages", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = textColor, - textAlign = TextAlign.Center, - lineHeight = 40.sp + text = "Your Keys,\nYour Messages", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = textColor, + textAlign = TextAlign.Center, + lineHeight = 40.sp ) } - + Spacer(modifier = Modifier.height(16.dp)) - + // Subtitle AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 300)) + slideInVertically( - initialOffsetY = { 50 }, - animationSpec = tween(600, delayMillis = 300) - ) + visible = visible, + enter = + fadeIn(tween(600, delayMillis = 300)) + + slideInVertically( + initialOffsetY = { 50 }, + animationSpec = tween(600, delayMillis = 300) + ) ) { Text( - text = "Secure messaging with\ncryptographic keys", - fontSize = 16.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center, - lineHeight = 24.sp, - modifier = Modifier.padding(horizontal = 8.dp) + text = "Secure messaging with\ncryptographic keys", + fontSize = 16.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center, + lineHeight = 24.sp, + modifier = Modifier.padding(horizontal = 8.dp) ) } - + Spacer(modifier = Modifier.height(24.dp)) - + // Features list with icons - placed above buttons - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 400)) - ) { + AnimatedVisibility(visible = visible, enter = fadeIn(tween(600, delayMillis = 400))) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly ) { CompactFeatureItem( - icon = Icons.Default.Security, - text = "Encrypted", - isDarkTheme = isDarkTheme, - textColor = textColor + icon = Icons.Default.Security, + text = "Encrypted", + isDarkTheme = isDarkTheme, + textColor = textColor ) CompactFeatureItem( - icon = Icons.Default.NoAccounts, - text = "No Phone", - isDarkTheme = isDarkTheme, - textColor = textColor + icon = Icons.Default.NoAccounts, + text = "No Phone", + isDarkTheme = isDarkTheme, + textColor = textColor ) CompactFeatureItem( - icon = Icons.Default.Key, - text = "Your Keys", - isDarkTheme = isDarkTheme, - textColor = textColor + icon = Icons.Default.Key, + text = "Your Keys", + isDarkTheme = isDarkTheme, + textColor = textColor ) } } - + Spacer(modifier = Modifier.height(32.dp)) - + // Create Seed Button AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically( - initialOffsetY = { 100 }, - animationSpec = tween(600, delayMillis = 500) - ) + visible = visible, + enter = + fadeIn(tween(600, delayMillis = 500)) + + slideInVertically( + initialOffsetY = { 100 }, + animationSpec = tween(600, delayMillis = 500) + ) ) { Button( - onClick = onCreateSeed, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - colors = ButtonDefaults.buttonColors( - containerColor = PrimaryBlue, - contentColor = Color.White - ), - shape = RoundedCornerShape(16.dp), - elevation = ButtonDefaults.buttonElevation( - defaultElevation = 0.dp, - pressedElevation = 0.dp - ) + onClick = onCreateSeed, + modifier = Modifier.fillMaxWidth().height(56.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = PrimaryBlue, + contentColor = Color.White + ), + shape = RoundedCornerShape(16.dp), + elevation = + ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ) ) { Icon( - imageVector = Icons.Default.Key, - contentDescription = null, - modifier = Modifier.size(22.dp) + imageVector = Icons.Default.Key, + contentDescription = null, + modifier = Modifier.size(22.dp) ) Spacer(modifier = Modifier.width(12.dp)) Text( - text = "Generate New Seed Phrase", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold + text = "Generate New Seed Phrase", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold ) } } - + Spacer(modifier = Modifier.height(12.dp)) - + // Import Seed Button AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically( - initialOffsetY = { 100 }, - animationSpec = tween(600, delayMillis = 600) - ) + visible = visible, + enter = + fadeIn(tween(600, delayMillis = 600)) + + slideInVertically( + initialOffsetY = { 100 }, + animationSpec = tween(600, delayMillis = 600) + ) ) { TextButton( - onClick = onImportSeed, - modifier = Modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(16.dp) + onClick = onImportSeed, + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(16.dp) ) { Icon( - imageVector = Icons.Default.Download, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = PrimaryBlue + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = PrimaryBlue ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "I Already Have a Seed Phrase", - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = PrimaryBlue + text = "I Already Have a Seed Phrase", + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = PrimaryBlue ) } } - + Spacer(modifier = Modifier.weight(0.15f)) } } @@ -264,70 +264,62 @@ fun WelcomeScreen( @Composable private fun CompactFeatureItem( - icon: androidx.compose.ui.graphics.vector.ImageVector, - text: String, - isDarkTheme: Boolean, - textColor: Color + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isDarkTheme: Boolean, + textColor: Color ) { Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(PrimaryBlue.copy(alpha = 0.12f)), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center ) { Icon( - imageVector = icon, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) + imageVector = icon, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) ) } Text( - text = text, - fontSize = 13.sp, - color = textColor.copy(alpha = 0.8f), - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center + text = text, + fontSize = 13.sp, + color = textColor.copy(alpha = 0.8f), + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center ) } } @Composable private fun FeatureItem( - icon: androidx.compose.ui.graphics.vector.ImageVector, - text: String, - isDarkTheme: Boolean, - textColor: Color + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isDarkTheme: Boolean, + textColor: Color ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(PrimaryBlue.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center ) { Icon( - imageVector = icon, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(20.dp) + imageVector = icon, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) ) } Spacer(modifier = Modifier.width(16.dp)) - Text( - text = text, - fontSize = 15.sp, - color = textColor, - fontWeight = FontWeight.Medium - ) + Text(text = text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Medium) } } 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 82ef059..9f64e37 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 @@ -1,5 +1,7 @@ package com.rosetta.messenger.ui.chats +import android.content.Context +import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.* @@ -13,14 +15,7 @@ import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape @@ -29,17 +24,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.Stable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.StrokeCap @@ -52,51 +47,43 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupProperties -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.viewmodel.compose.viewModel +import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition +import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator +import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator +import com.airbnb.lottie.compose.* import com.rosetta.messenger.R +import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.Message import com.rosetta.messenger.network.DeliveryStatus import com.rosetta.messenger.network.SearchUser -import com.rosetta.messenger.ui.components.AppleEmojiPickerPanel -import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.AppleEmojiText import com.rosetta.messenger.ui.components.AppleEmojiTextField +import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator -import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator -import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition -import android.view.inputmethod.InputMethodManager -import android.content.Context -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import com.airbnb.lottie.compose.* -import com.rosetta.messenger.data.ForwardManager import java.text.SimpleDateFormat import java.util.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable // Telegram's CubicBezier interpolator (0.199, 0.010, 0.279, 0.910) val TelegramEasing = CubicBezierEasing(0.199f, 0.010f, 0.279f, 0.910f) // 🚀 Помечаем классы как Stable для оптимизации рекомпозиций -@Stable -class StableSearchUser(val user: SearchUser) +@Stable class StableSearchUser(val user: SearchUser) /** Telegram Send Icon (горизонтальный самолетик) - кастомная SVG иконка */ private val TelegramSendIcon: ImageVector @@ -144,10 +131,10 @@ private val TelegramSendIcon: ImageVector /** Данные цитируемого сообщения */ data class ReplyData( - val messageId: String, - val senderName: String, // Имя отправителя цитируемого сообщения - val text: String, - val isFromMe: Boolean // Цитируемое сообщение от меня? + val messageId: String, + val senderName: String, // Имя отправителя цитируемого сообщения + val text: String, + val isFromMe: Boolean // Цитируемое сообщение от меня? ) /** Модель сообщения (Legacy - для совместимости) */ @@ -158,7 +145,7 @@ data class ChatMessage( val timestamp: Date, val status: MessageStatus = MessageStatus.SENT, val showDateHeader: Boolean = false, // Показывать ли разделитель даты - val replyData: ReplyData? = null // Данные цитируемого сообщения + val replyData: ReplyData? = null // Данные цитируемого сообщения ) enum class MessageStatus { @@ -166,7 +153,7 @@ enum class MessageStatus { SENT, DELIVERED, READ, - ERROR // 🔥 Ошибка отправки (таймаут или реальная ошибка) + ERROR // 🔥 Ошибка отправки (таймаут или реальная ошибка) } // 🔥 Константа таймаута доставки (как в архиве - 80 секунд) @@ -174,11 +161,12 @@ private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L /** Проверка: сообщение ещё может быть доставлено (не истёк таймаут) */ private fun isMessageDeliveredByTime(timestamp: Long, attachmentsCount: Int = 0): Boolean { - val maxTime = if (attachmentsCount > 0) { - MESSAGE_MAX_TIME_TO_DELIVERED_MS * attachmentsCount - } else { - MESSAGE_MAX_TIME_TO_DELIVERED_MS - } + val maxTime = + if (attachmentsCount > 0) { + MESSAGE_MAX_TIME_TO_DELIVERED_MS * attachmentsCount + } else { + MESSAGE_MAX_TIME_TO_DELIVERED_MS + } return System.currentTimeMillis() - timestamp < maxTime } @@ -215,7 +203,11 @@ private fun Message.toChatMessage() = ) /** Экран детального чата с пользователем */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalComposeUiApi::class, + ExperimentalFoundationApi::class +) @Composable fun ChatDetailScreen( user: SearchUser, @@ -224,23 +216,24 @@ fun ChatDetailScreen( isDarkTheme: Boolean, onBack: () -> Unit, onUserProfileClick: () -> Unit = {}, - onNavigateToChat: (String) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward) + onNavigateToChat: (String) -> Unit = {}, // 📨 Callback для навигации в другой чат (Forward) viewModel: ChatViewModel = viewModel() ) { // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование из композиции чтобы не засорять logcat - + val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current val context = LocalContext.current val view = LocalView.current val database = remember { com.rosetta.messenger.database.RosettaDatabase.getDatabase(context) } - + // 🔔 Badge: количество непрочитанных сообщений из других чатов - val totalUnreadFromOthers by database.dialogDao() - .getTotalUnreadCountExcludingFlow(currentUserPublicKey, user.publicKey) - .collectAsState(initial = 0) - + val totalUnreadFromOthers by + database.dialogDao() + .getTotalUnreadCountExcludingFlow(currentUserPublicKey, user.publicKey) + .collectAsState(initial = 0) + // Цвета как в React Native themes.ts val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black @@ -254,35 +247,37 @@ fun ChatDetailScreen( val listState = rememberLazyListState() val scope = rememberCoroutineScope() val density = LocalDensity.current - + // 🔥 State для подсветки сообщения при клике на reply var highlightedMessageId by remember { mutableStateOf(null) } - + // 🔥 Emoji picker state (поднят из MessageInputBar для KeyboardAvoidingView) var showEmojiPicker by remember { mutableStateOf(false) } - + // 🎯 Координатор плавных переходов клавиатуры (Telegram-style) val coordinator = rememberKeyboardTransitionCoordinator() - + // 🔥 ОПТИМИЗАЦИЯ: НЕ читаем imeHeight напрямую в композиции! // Используем snapshotFlow чтобы избежать рекомпозиции на каждый пиксель анимации val imeInsets = WindowInsets.ime - + // 🔥 Синхронизируем coordinator с IME высотой через snapshotFlow (БЕЗ рекомпозиции!) LaunchedEffect(Unit) { - snapshotFlow { - with(density) { imeInsets.getBottom(density).toDp() } - }.collect { currentImeHeight -> + snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { + currentImeHeight -> coordinator.updateKeyboardHeight(currentImeHeight) if (currentImeHeight > 100.dp) { coordinator.syncHeights() } } } - + // 🔥 Инициализируем высоту emoji панели из сохранённой высоты клавиатуры LaunchedEffect(Unit) { - val savedHeightPx = com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight(context) + val savedHeightPx = + com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight( + context + ) if (savedHeightPx > 0) { val savedHeightDp = with(density) { savedHeightPx.toDp() } coordinator.initializeEmojiHeight(savedHeightDp) @@ -290,25 +285,25 @@ fun ChatDetailScreen( coordinator.initializeEmojiHeight(280.dp) // fallback } } - + // 🔥 Reply/Forward state val replyMessages by viewModel.replyMessages.collectAsState() val hasReply = replyMessages.isNotEmpty() - + // 🔥 Snapshot последнего непустого состояния для отображения во время анимации закрытия // Используем rememberSaveable с mutableStateOf чтобы сохранять данные пока панель закрывается var displayReplyMessages by remember { mutableStateOf(replyMessages) } - + // Обновляем snapshot только когда появляются новые данные (не пустые) LaunchedEffect(replyMessages) { if (replyMessages.isNotEmpty()) { displayReplyMessages = replyMessages } } - + // 🔥 FocusRequester для автофокуса на инпут при reply val inputFocusRequester = remember { FocusRequester() } - + // 🔥 Автофокус на инпут при появлении reply панели LaunchedEffect(hasReply) { if (hasReply) { @@ -330,15 +325,14 @@ fun ChatDetailScreen( } // Флаг для скрытия кнопки scroll при отправке (чтобы не мигала) var isSendingMessage by remember { mutableStateOf(false) } - + // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() - + // Логирование изменений selection mode - LaunchedEffect(isSelectionMode, selectedMessages.size) { - } - + LaunchedEffect(isSelectionMode, selectedMessages.size) {} + // 🔥 Backup: если клавиатура ещё открыта когда selection mode активировался // (клавиатура уже должна быть закрыта в onLongClick, это только backup) LaunchedEffect(isSelectionMode) { @@ -362,14 +356,14 @@ fun ChatDetailScreen( val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } - + // 📨 Forward: показывать ли выбор чата var showForwardPicker by remember { mutableStateOf(false) } - + // 📨 Forward: список диалогов для выбора (загружаем из базы) val chatsListViewModel: ChatsListViewModel = viewModel() val dialogsList by chatsListViewModel.dialogs.collectAsState() - + // 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) { if (currentUserPublicKey.isNotEmpty() && currentUserPrivateKey.isNotEmpty()) { @@ -377,18 +371,17 @@ fun ChatDetailScreen( } } - // Состояние выпадающего меню var showMenu by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } var showBlockConfirm by remember { mutableStateOf(false) } var showUnblockConfirm by remember { mutableStateOf(false) } - + // Проверяем, заблокирован ли пользователь (отложенно, не блокирует UI) var isBlocked by remember { mutableStateOf(false) } LaunchedEffect(user.publicKey, currentUserPublicKey) { // Отложенная проверка - не блокирует анимацию - kotlinx.coroutines.delay(50) // Даём анимации завершиться + kotlinx.coroutines.delay(50) // Даём анимации завершиться isBlocked = database.blacklistDao().isUserBlocked(user.publicKey, currentUserPublicKey) } @@ -397,8 +390,8 @@ fun ChatDetailScreen( val inputText by viewModel.inputText.collectAsState() val isTyping by viewModel.opponentTyping.collectAsState() val isOnline by viewModel.opponentOnline.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона - + val isLoading by viewModel.isLoading.collectAsState() // 🔥 Для скелетона + // 🔥 Reply/Forward state (replyMessages и hasReply определены выше для listBottomPadding) val isForwardMode by viewModel.isForwardMode.collectAsState() @@ -417,46 +410,46 @@ fun ChatDetailScreen( val dateString = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) .format(message.timestamp) - + // Показываем дату если это последнее сообщение за день // (следующее сообщение - другой день или нет следующего) val nextMessage = sortedMessages.getOrNull(i + 1) - val nextDateString = nextMessage?.let { - SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(it.timestamp) - } + val nextDateString = + nextMessage?.let { + SimpleDateFormat("yyyyMMdd", Locale.getDefault()) + .format(it.timestamp) + } val showDate = nextDateString == null || nextDateString != dateString - + result.add(message to showDate) lastDateString = dateString } result } - + // 🔥 Функция для скролла к сообщению с подсветкой val scrollToMessage: (String) -> Unit = { messageId -> - + // Логируем все ID сообщений для отладки - messagesWithDates.forEachIndexed { index, pair -> - } - + messagesWithDates.forEachIndexed { index, pair -> } + scope.launch { // 🔥 Сбрасываем текущую подсветку перед новым скроллом highlightedMessageId = null delay(50) // Небольшая задержка для сброса анимации - + // Находим индекс сообщения в списке val messageIndex = messagesWithDates.indexOfFirst { it.first.id == messageId } if (messageIndex != -1) { // Скроллим к сообщению listState.animateScrollToItem(messageIndex) - + // Подсвечиваем на 2 секунды highlightedMessageId = messageId delay(2000) highlightedMessageId = null - } else { - } + } else {} } } @@ -471,13 +464,9 @@ fun ChatDetailScreen( // 🔥 Обработка системной кнопки назад BackHandler { hideKeyboardAndBack() } - + // 🔥 Cleanup при выходе из экрана - только закрываем диалог - DisposableEffect(Unit) { - onDispose { - viewModel.closeDialog() - } - } + DisposableEffect(Unit) { onDispose { viewModel.closeDialog() } } // Инициализируем ViewModel с ключами и открываем диалог LaunchedEffect(user.publicKey) { @@ -510,61 +499,61 @@ fun ChatDetailScreen( // Аватар - используем publicKey для консистентности цвета везде val avatarColors = - getAvatarColor( - if (isSavedMessages) "SavedMessages" else user.publicKey, - isDarkTheme - ) + getAvatarColor(if (isSavedMessages) "SavedMessages" else user.publicKey, isDarkTheme) // � Edge swipe to go back (iOS/Telegram style) var edgeSwipeOffset by remember { mutableStateOf(0f) } val edgeSwipeThreshold = 100f // px threshold для активации val edgeZoneWidth = 30f // px зона от левого края для начала свайпа var isEdgeSwiping by remember { mutableStateOf(false) } - + // Анимация возврата - val animatedEdgeOffset by animateFloatAsState( - targetValue = edgeSwipeOffset, - animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), - label = "edgeSwipe" - ) + val animatedEdgeOffset by + animateFloatAsState( + targetValue = edgeSwipeOffset, + animationSpec = spring(dampingRatio = 0.8f, stiffness = 300f), + label = "edgeSwipe" + ) // 🚀 Весь контент без дополнительной анимации (анимация в MainActivity) Box( - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragStart = { offset -> - // Начинаем свайп только если палец у левого края - isEdgeSwiping = offset.x < edgeZoneWidth - }, - onDragEnd = { - if (isEdgeSwiping && edgeSwipeOffset > edgeSwipeThreshold) { - // Свайп достаточный - переходим назад - hideKeyboardAndBack() - } - edgeSwipeOffset = 0f - isEdgeSwiping = false - }, - onDragCancel = { - edgeSwipeOffset = 0f - isEdgeSwiping = false - }, - onHorizontalDrag = { _, dragAmount -> - if (isEdgeSwiping) { - // Только вправо (положительный dragAmount) - val newOffset = edgeSwipeOffset + dragAmount - edgeSwipeOffset = newOffset.coerceIn(0f, 300f) - } - } - ) - } - .graphicsLayer { - // Сдвигаем контент при свайпе - translationX = animatedEdgeOffset - // Легкое затемнение при свайпе - alpha = 1f - (animatedEdgeOffset / 600f).coerceIn(0f, 0.3f) - } + modifier = + Modifier.fillMaxSize() + .pointerInput(Unit) { + detectHorizontalDragGestures( + onDragStart = { offset -> + // Начинаем свайп только если палец у левого края + isEdgeSwiping = offset.x < edgeZoneWidth + }, + onDragEnd = { + if (isEdgeSwiping && + edgeSwipeOffset > edgeSwipeThreshold + ) { + // Свайп достаточный - переходим назад + hideKeyboardAndBack() + } + edgeSwipeOffset = 0f + isEdgeSwiping = false + }, + onDragCancel = { + edgeSwipeOffset = 0f + isEdgeSwiping = false + }, + onHorizontalDrag = { _, dragAmount -> + if (isEdgeSwiping) { + // Только вправо (положительный dragAmount) + val newOffset = edgeSwipeOffset + dragAmount + edgeSwipeOffset = newOffset.coerceIn(0f, 300f) + } + } + ) + } + .graphicsLayer { + // Сдвигаем контент при свайпе + translationX = animatedEdgeOffset + // Легкое затемнение при свайпе + alpha = 1f - (animatedEdgeOffset / 600f).coerceIn(0f, 0.3f) + } ) { // Telegram-style solid header background (без blur) val headerBackground = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) @@ -574,90 +563,119 @@ fun ChatDetailScreen( topBar = { // 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри Box( - modifier = Modifier - .fillMaxWidth() - .background(if (isSelectionMode) { - if (isDarkTheme) Color(0xFF212121) else Color.White - } else headerBackground) + modifier = + Modifier.fillMaxWidth() + .background( + if (isSelectionMode) { + if (isDarkTheme) Color(0xFF212121) + else Color.White + } else headerBackground + ) ) { // Контент хедера с Crossfade для плавной смены Crossfade( - targetState = isSelectionMode, - animationSpec = tween(200), - label = "headerContent" + targetState = isSelectionMode, + animationSpec = tween(200), + label = "headerContent" ) { selectionMode -> if (selectionMode) { // SELECTION MODE CONTENT Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .height(56.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .height(56.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { // Left: X (cancel) + Count Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = { selectedMessages = emptySet() }) { Icon( - Icons.Default.Close, - contentDescription = "Cancel", - tint = if (isDarkTheme) Color.White else Color.Black, - modifier = Modifier.size(24.dp) + Icons.Default.Close, + contentDescription = "Cancel", + tint = + if (isDarkTheme) Color.White + else Color.Black, + modifier = Modifier.size(24.dp) ) } Spacer(modifier = Modifier.width(8.dp)) Text( - "${selectedMessages.size}", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = if (isDarkTheme) Color.White else Color.Black + "${selectedMessages.size}", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + color = + if (isDarkTheme) Color.White + else Color.Black ) } - + // Right: Action buttons Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically ) { // Copy button IconButton( - onClick = { - val textToCopy = messages - .filter { selectedMessages.contains(it.id) } - .sortedBy { it.timestamp } - .joinToString("\n\n") { msg -> - val time = SimpleDateFormat("HH:mm", Locale.getDefault()) - .format(msg.timestamp) - "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" - } - clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(textToCopy)) - selectedMessages = emptySet() - } + onClick = { + val textToCopy = + messages + .filter { + selectedMessages.contains( + it.id + ) + } + .sortedBy { it.timestamp } + .joinToString("\n\n") { msg -> + val time = + SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ) + .format( + msg.timestamp + ) + "[${if (msg.isOutgoing) "You" else chatTitle}] $time\n${msg.text}" + } + clipboardManager.setText( + androidx.compose.ui.text + .AnnotatedString(textToCopy) + ) + selectedMessages = emptySet() + } ) { Icon( - Icons.Default.ContentCopy, - contentDescription = "Copy", - tint = if (isDarkTheme) Color.White else Color.Black, - modifier = Modifier.size(22.dp) + Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = + if (isDarkTheme) Color.White + else Color.Black, + modifier = Modifier.size(22.dp) ) } - + // Delete button IconButton( - onClick = { - messages - .filter { selectedMessages.contains(it.id) } - .forEach { msg -> viewModel.deleteMessage(msg.id) } - selectedMessages = emptySet() - } + onClick = { + messages + .filter { + selectedMessages.contains(it.id) + } + .forEach { msg -> + viewModel.deleteMessage(msg.id) + } + selectedMessages = emptySet() + } ) { Icon( - Icons.Default.Delete, - contentDescription = "Delete", - tint = if (isDarkTheme) Color.White else Color.Black, - modifier = Modifier.size(22.dp) + Icons.Default.Delete, + contentDescription = "Delete", + tint = + if (isDarkTheme) Color.White + else Color.Black, + modifier = Modifier.size(22.dp) ) } } @@ -665,185 +683,208 @@ fun ChatDetailScreen( } else { // NORMAL HEADER CONTENT Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .height(56.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .height(56.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically ) { // Back button with badge Box { IconButton( - onClick = hideKeyboardAndBack, - modifier = Modifier.size(40.dp) + onClick = hideKeyboardAndBack, + modifier = Modifier.size(40.dp) ) { Icon( - Icons.Default.KeyboardArrowLeft, - contentDescription = "Back", - tint = headerIconColor, - modifier = Modifier.size(32.dp) + Icons.Default.KeyboardArrowLeft, + contentDescription = "Back", + tint = headerIconColor, + modifier = Modifier.size(32.dp) ) } if (totalUnreadFromOthers > 0) { Box( - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = (-4).dp, y = 6.dp) - .size(if (totalUnreadFromOthers > 9) 20.dp else 18.dp) - .clip(CircleShape) - .background(Color(0xFFFF3B30)), - contentAlignment = Alignment.Center + modifier = + Modifier.align(Alignment.TopEnd) + .offset(x = (-4).dp, y = 6.dp) + .size( + if (totalUnreadFromOthers > + 9 + ) + 20.dp + else 18.dp + ) + .clip(CircleShape) + .background(Color(0xFFFF3B30)), + contentAlignment = Alignment.Center ) { Text( - text = if (totalUnreadFromOthers > 99) "99+" - else "$totalUnreadFromOthers", - color = Color.White, - fontSize = if (totalUnreadFromOthers > 9) 9.sp else 10.sp, - fontWeight = FontWeight.Bold, - maxLines = 1 + text = + if (totalUnreadFromOthers > 99) + "99+" + else "$totalUnreadFromOthers", + color = Color.White, + fontSize = + if (totalUnreadFromOthers > 9) 9.sp + else 10.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 ) } } } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(4.dp)) - // Аватар - Box( - modifier = - Modifier.size(40.dp) - .clip(CircleShape) - .background( - if (isSavedMessages) PrimaryBlue - else avatarColors.backgroundColor - ) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { + // Аватар + Box( + modifier = + Modifier.size(40.dp) + .clip(CircleShape) + .background( + if (isSavedMessages) PrimaryBlue + else + avatarColors + .backgroundColor + ) + .clickable( + indication = null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { + keyboardController?.hide() + focusManager.clearFocus() + onUserProfileClick() + }, + contentAlignment = Alignment.Center + ) { + if (isSavedMessages) { + Icon( + Icons.Default.Bookmark, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } else { + Text( + text = + if (user.title.isNotEmpty()) + getInitials(user.title) + else user.publicKey.take(2).uppercase(), + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor + ) + } + } + + Spacer(modifier = Modifier.width(12.dp)) + + // Информация о пользователе + Column( + modifier = + Modifier.weight(1f).clickable( + indication = null, + interactionSource = + remember { + MutableInteractionSource() + } + ) { keyboardController?.hide() focusManager.clearFocus() onUserProfileClick() - }, - contentAlignment = Alignment.Center - ) { - if (isSavedMessages) { - Icon( - Icons.Default.Bookmark, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - } else { - Text( - text = - if (user.title.isNotEmpty()) - getInitials(user.title) - else user.publicKey.take(2).uppercase(), - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor - ) - } - } + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = chatTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!isSavedMessages && user.verified > 0) { + Spacer(modifier = Modifier.width(4.dp)) + VerifiedBadge(verified = user.verified, size = 16) + } + } + // Typing indicator или subtitle + if (isTyping) { + TypingIndicator(isDarkTheme = isDarkTheme) + } else { + Text( + text = chatSubtitle, + fontSize = 13.sp, + color = + when { + isSavedMessages -> + secondaryTextColor + isOnline -> + Color( + 0xFF38B24D + ) // Зелёный когда онлайн + else -> secondaryTextColor // Серый + // для + // offline + }, + maxLines = 1 + ) + } + } + // Кнопки действий + if (!isSavedMessages) { + IconButton(onClick = { /* TODO: Voice call */}) { + Icon( + Icons.Default.Call, + contentDescription = "Call", + tint = headerIconColor.copy(alpha = 0.6f) + ) + } + } - Spacer(modifier = Modifier.width(12.dp)) - - // Информация о пользователе - Column( - modifier = - Modifier.weight(1f).clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { + // Кнопка меню - открывает bottom sheet + IconButton( + onClick = { + // Закрываем клавиатуру перед открытием меню keyboardController?.hide() focusManager.clearFocus() - onUserProfileClick() - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = chatTitle, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (!isSavedMessages && user.verified > 0) { - Spacer(modifier = Modifier.width(4.dp)) - VerifiedBadge(verified = user.verified, size = 16) + // Даём клавиатуре время закрыться перед показом + // bottom sheet + scope.launch { + delay( + 150 + ) // Задержка для плавного закрытия клавиатуры + showMenu = true + } + }, + modifier = Modifier.size(48.dp).clip(CircleShape) + ) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More", + tint = headerIconColor.copy(alpha = 0.6f), + modifier = Modifier.size(26.dp) + ) } } - // Typing indicator или subtitle - if (isTyping) { - TypingIndicator(isDarkTheme = isDarkTheme) - } else { - Text( - text = chatSubtitle, - fontSize = 13.sp, - color = - when { - isSavedMessages -> secondaryTextColor - isOnline -> - Color( - 0xFF38B24D - ) // Зелёный когда онлайн - else -> secondaryTextColor // Серый для offline - }, - maxLines = 1 - ) - } - } - // Кнопки действий - if (!isSavedMessages) { - IconButton(onClick = { /* TODO: Voice call */}) { - Icon( - Icons.Default.Call, - contentDescription = "Call", - tint = headerIconColor.copy(alpha = 0.6f) - ) - } - } - - // Кнопка меню - открывает bottom sheet - IconButton( - onClick = { - // Закрываем клавиатуру перед открытием меню - keyboardController?.hide() - focusManager.clearFocus() - // Даём клавиатуре время закрыться перед показом bottom sheet - scope.launch { - delay(150) // Задержка для плавного закрытия клавиатуры - showMenu = true - } - }, - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - ) { - Icon( - Icons.Default.MoreVert, - contentDescription = "More", - tint = headerIconColor.copy(alpha = 0.6f), - modifier = Modifier.size(26.dp) - ) - } - } } } // Закрытие Crossfade - + // Bottom line для unified header Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.15f) - else Color.Black.copy(alpha = 0.1f) - ) + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) + Color.White.copy(alpha = 0.15f) + else Color.Black.copy(alpha = 0.1f) + ) ) } // Закрытие Box unified header }, @@ -856,234 +897,302 @@ fun ChatDetailScreen( // isEmojiBoxVisible учитывает анимацию fade-out (alpha > 0.01) // 🔥 В selection mode НЕ используем imePadding (клавиатура закрыта) val useImePadding = !coordinator.isEmojiBoxVisible && !isSelectionMode - val bottomModifier = if (useImePadding) { - Modifier.imePadding() // С imePadding - клавиатура поднимает инпут - } else { - Modifier // Без imePadding - } - - // Логирование состояния - LaunchedEffect(isSelectionMode, useImePadding, coordinator.isEmojiBoxVisible, coordinator.keyboardHeight) { - } - - Column(modifier = bottomModifier) { - // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется внутри с плавной анимацией - AnimatedContent( - targetState = isSelectionMode, - transitionSpec = { - if (targetState) { - // Selection mode появляется: снизу вверх - slideInVertically( - animationSpec = tween(300, easing = TelegramEasing), - initialOffsetY = { it } - ) + fadeIn(animationSpec = tween(200)) togetherWith - slideOutVertically( - animationSpec = tween(300, easing = TelegramEasing), - targetOffsetY = { -it } - ) + fadeOut(animationSpec = tween(150)) + val bottomModifier = + if (useImePadding) { + Modifier.imePadding() // С imePadding - клавиатура поднимает инпут } else { - // Input bar возвращается: снизу вверх - slideInVertically( - animationSpec = tween(300, easing = TelegramEasing), - initialOffsetY = { it } - ) + fadeIn(animationSpec = tween(200)) togetherWith - slideOutVertically( - animationSpec = tween(300, easing = TelegramEasing), - targetOffsetY = { it } - ) + fadeOut(animationSpec = tween(150)) + Modifier // Без imePadding } - }, - label = "bottomBarContent" - ) { selectionMode -> - - if (selectionMode) { - // SELECTION ACTION BAR - Reply/Forward - // 🔥 Высота должна совпадать с MessageInputBar (~56dp content + nav bar) - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor) - ) { - // Border сверху - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.1f)) - ) - - // Кнопки Reply и Forward - плавная анимация появления - val buttonScale by animateFloatAsState( - targetValue = if (selectionMode) 1f else 0.95f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "buttonScale" - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .navigationBarsPadding() - .graphicsLayer { - scaleX = buttonScale - scaleY = buttonScale - }, - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Reply button - идентичной высоты с инпутом - Box( - modifier = Modifier - .weight(1f) - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(PrimaryBlue.copy(alpha = 0.1f)) - .clickable { - val selectedMsgs = messages - .filter { selectedMessages.contains(it.id) } - .sortedBy { it.timestamp } - viewModel.setReplyMessages(selectedMsgs) - selectedMessages = emptySet() - }, - contentAlignment = Alignment.Center - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - Icons.Default.Reply, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Reply", - color = PrimaryBlue, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ) - } + + // Логирование состояния + LaunchedEffect( + isSelectionMode, + useImePadding, + coordinator.isEmojiBoxVisible, + coordinator.keyboardHeight + ) {} + + Column(modifier = bottomModifier) { + // 🔥 UNIFIED BOTTOM BAR - один контейнер, контент меняется внутри с плавной + // анимацией + AnimatedContent( + targetState = isSelectionMode, + transitionSpec = { + if (targetState) { + // Selection mode появляется: снизу вверх + slideInVertically( + animationSpec = tween(300, easing = TelegramEasing), + initialOffsetY = { it } + ) + fadeIn(animationSpec = tween(200)) togetherWith + slideOutVertically( + animationSpec = + tween(300, easing = TelegramEasing), + targetOffsetY = { -it } + ) + fadeOut(animationSpec = tween(150)) + } else { + // Input bar возвращается: снизу вверх + slideInVertically( + animationSpec = tween(300, easing = TelegramEasing), + initialOffsetY = { it } + ) + fadeIn(animationSpec = tween(200)) togetherWith + slideOutVertically( + animationSpec = + tween(300, easing = TelegramEasing), + targetOffsetY = { it } + ) + fadeOut(animationSpec = tween(150)) } - - // Forward button - идентичной высоты с инпутом + }, + label = "bottomBarContent" + ) { selectionMode -> + if (selectionMode) { + // SELECTION ACTION BAR - Reply/Forward + // 🔥 Высота должна совпадать с MessageInputBar (~56dp content + nav + // bar) + Column( + modifier = + Modifier.fillMaxWidth().background(backgroundColor) + ) { + // Border сверху Box( - modifier = Modifier - .weight(1f) - .height(48.dp) - .clip(RoundedCornerShape(12.dp)) - .background(PrimaryBlue.copy(alpha = 0.1f)) - .clickable { - // 📨 Сохраняем сообщения в ForwardManager и показываем выбор чата - val selectedMsgs = messages - .filter { selectedMessages.contains(it.id) } - .sortedBy { it.timestamp } - - val forwardMessages = selectedMsgs.map { msg -> - ForwardManager.ForwardMessage( - messageId = msg.id, - text = msg.text, - timestamp = msg.timestamp.time, - isOutgoing = msg.isOutgoing, - senderPublicKey = if (msg.isOutgoing) currentUserPublicKey else user.publicKey, - originalChatPublicKey = user.publicKey - ) - } - ForwardManager.setForwardMessages(forwardMessages, showPicker = false) - selectedMessages = emptySet() - showForwardPicker = true - }, - contentAlignment = Alignment.Center + modifier = + Modifier.fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) + Color.White.copy( + alpha = 0.15f + ) + else + Color.Black.copy( + alpha = 0.1f + ) + ) + ) + + // Кнопки Reply и Forward - плавная анимация появления + val buttonScale by + animateFloatAsState( + targetValue = if (selectionMode) 1f else 0.95f, + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioMediumBouncy, + stiffness = + Spring.StiffnessMedium + ), + label = "buttonScale" + ) + + Row( + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = 12.dp, + vertical = 8.dp + ) + .navigationBarsPadding() + .graphicsLayer { + scaleX = buttonScale + scaleY = buttonScale + }, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + // Reply button - идентичной высоты с инпутом + Box( + modifier = + Modifier.weight(1f) + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + PrimaryBlue.copy( + alpha = 0.1f + ) + ) + .clickable { + val selectedMsgs = + messages + .filter { + selectedMessages + .contains( + it.id + ) + } + .sortedBy { + it.timestamp + } + viewModel.setReplyMessages( + selectedMsgs + ) + selectedMessages = emptySet() + }, + contentAlignment = Alignment.Center ) { - Icon( - Icons.Default.Forward, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Forward", - color = PrimaryBlue, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Reply, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Reply", + color = PrimaryBlue, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + } + } + + // Forward button - идентичной высоты с инпутом + Box( + modifier = + Modifier.weight(1f) + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background( + PrimaryBlue.copy( + alpha = 0.1f + ) + ) + .clickable { + // 📨 Сохраняем сообщения в + // ForwardManager и показываем + // выбор чата + val selectedMsgs = + messages + .filter { + selectedMessages + .contains( + it.id + ) + } + .sortedBy { + it.timestamp + } + + val forwardMessages = + selectedMsgs.map { msg + -> + ForwardManager + .ForwardMessage( + messageId = + msg.id, + text = + msg.text, + timestamp = + msg.timestamp + .time, + isOutgoing = + msg.isOutgoing, + senderPublicKey = + if (msg.isOutgoing + ) + currentUserPublicKey + else + user.publicKey, + originalChatPublicKey = + user.publicKey + ) + } + ForwardManager + .setForwardMessages( + forwardMessages, + showPicker = + false + ) + selectedMessages = emptySet() + showForwardPicker = true + }, + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Icon( + Icons.Default.Forward, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "Forward", + color = PrimaryBlue, + fontSize = 15.sp, + fontWeight = FontWeight.SemiBold + ) + } } } } - } - } else { - // INPUT BAR - Column { - MessageInputBar( - value = inputText, - onValueChange = { - viewModel.updateInputText(it) - if (it.isNotEmpty() && !isSavedMessages) { - viewModel.sendTypingIndicator() - } - }, - onSend = { - isSendingMessage = true - viewModel.sendMessage() - scope.launch { - delay(100) - listState.animateScrollToItem(0) - delay(300) - isSendingMessage = false - } - }, - isDarkTheme = isDarkTheme, - backgroundColor = backgroundColor, - textColor = textColor, - placeholderColor = secondaryTextColor, - secondaryTextColor = secondaryTextColor, - replyMessages = replyMessages, - isForwardMode = isForwardMode, - onCloseReply = { viewModel.clearReplyMessages() }, - chatTitle = chatTitle, - isBlocked = isBlocked, - showEmojiPicker = showEmojiPicker, - onToggleEmojiPicker = { showEmojiPicker = it }, - focusRequester = inputFocusRequester, - coordinator = coordinator, - displayReplyMessages = displayReplyMessages, - onReplyClick = scrollToMessage - ) + } else { + // INPUT BAR + Column { + MessageInputBar( + value = inputText, + onValueChange = { + viewModel.updateInputText(it) + if (it.isNotEmpty() && !isSavedMessages) { + viewModel.sendTypingIndicator() + } + }, + onSend = { + isSendingMessage = true + viewModel.sendMessage() + scope.launch { + delay(100) + listState.animateScrollToItem(0) + delay(300) + isSendingMessage = false + } + }, + isDarkTheme = isDarkTheme, + backgroundColor = backgroundColor, + textColor = textColor, + placeholderColor = secondaryTextColor, + secondaryTextColor = secondaryTextColor, + replyMessages = replyMessages, + isForwardMode = isForwardMode, + onCloseReply = { viewModel.clearReplyMessages() }, + chatTitle = chatTitle, + isBlocked = isBlocked, + showEmojiPicker = showEmojiPicker, + onToggleEmojiPicker = { showEmojiPicker = it }, + focusRequester = inputFocusRequester, + coordinator = coordinator, + displayReplyMessages = displayReplyMessages, + onReplyClick = scrollToMessage + ) + } } } - } } // Закрытие Column с imePadding } ) { paddingValues -> // 🔥 Column структура - список сжимается когда клавиатура открывается // imePadding применён к bottomBar, поэтому контент автоматически сжимается Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) // 🔥 Учитываем top и bottom padding от Scaffold - .background(backgroundColor) + modifier = + Modifier.fillMaxSize() + .padding( + paddingValues + ) // 🔥 Учитываем top и bottom padding от Scaffold + .background(backgroundColor) ) { // Список сообщений - занимает всё доступное место - Box( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { when { // 🔥 СКЕЛЕТОН - показываем пока загружаются сообщения isLoading -> { MessageSkeletonList( - isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() ) } // Пустое состояние (нет сообщений) @@ -1094,26 +1203,34 @@ fun ChatDetailScreen( verticalArrangement = Arrangement.Center ) { if (isSavedMessages) { - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.saved)) - val progress by animateLottieCompositionAsState( - composition = composition, - iterations = LottieConstants.IterateForever - ) + val composition by + rememberLottieComposition( + LottieCompositionSpec.RawRes(R.raw.saved) + ) + val progress by + animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever + ) LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(120.dp) + composition = composition, + progress = { progress }, + modifier = Modifier.size(120.dp) ) } else { - val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.speech)) - val progress by animateLottieCompositionAsState( - composition = composition, - iterations = LottieConstants.IterateForever - ) + val composition by + rememberLottieComposition( + LottieCompositionSpec.RawRes(R.raw.speech) + ) + val progress by + animateLottieCompositionAsState( + composition = composition, + iterations = LottieConstants.IterateForever + ) LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(120.dp) + composition = composition, + progress = { progress }, + modifier = Modifier.size(120.dp) ) } Spacer(modifier = Modifier.height(16.dp)) @@ -1138,143 +1255,186 @@ fun ChatDetailScreen( } } // Есть сообщения - else -> LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize() - .nestedScroll( - remember { - object : NestedScrollConnection { - override fun onPreScroll( - available: Offset, - source: NestedScrollSource - ): Offset { - // Отслеживаем ручную прокрутку - // пользователем - if (source == NestedScrollSource.Drag) { - wasManualScroll = true - } - return Offset.Zero - } - } - } - ), - // padding для контента списка - // 🔥 Убираем horizontal padding чтобы выделение было edge-to-edge - // 🔥 Увеличиваем bottom padding когда активен selection mode (Reply/Forward панель) - contentPadding = PaddingValues( - start = 0.dp, - end = 0.dp, - top = 8.dp, - bottom = if (isSelectionMode) 100.dp else 16.dp // 🔥 Уменьшено для инпута - ), - reverseLayout = true - ) { - // Reversed layout: item 0 = самое новое сообщение (внизу экрана) - // messagesWithDates уже отсортирован новые->старые - // 🔥 Используем только id как ключ - без index, чтобы избежать прыгания при добавлении новых сообщений - itemsIndexed(messagesWithDates, key = { _, item -> - item.first.id - }) { - index, - (message, showDate) -> - // Определяем, показывать ли хвостик (последнее сообщение в группе) - val nextMessage = messagesWithDates.getOrNull(index + 1)?.first - val showTail = nextMessage == null || - nextMessage.isOutgoing != message.isOutgoing || - (message.timestamp.time - nextMessage.timestamp.time) > 60_000 // 1 минута - - // 🚀 ОПТИМИЗАЦИЯ: animateItemPlacement() для плавной анимации при добавлении/удалении - // Это предотвращает "прыжки" пузырьков при изменении списка - Column( - modifier = Modifier.animateItemPlacement( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ) - ) + else -> + LazyColumn( + state = listState, + modifier = + Modifier.fillMaxSize() + .nestedScroll( + remember { + object : + NestedScrollConnection { + override fun onPreScroll( + available: Offset, + source: + NestedScrollSource + ): Offset { + // Отслеживаем ручную + // прокрутку + // пользователем + if (source == + NestedScrollSource + .Drag + ) { + wasManualScroll = + true + } + return Offset.Zero + } + } + } + ), + // padding для контента списка + // 🔥 Убираем horizontal padding чтобы выделение было + // edge-to-edge + // 🔥 Увеличиваем bottom padding когда активен selection + // mode (Reply/Forward панель) + contentPadding = + PaddingValues( + start = 0.dp, + end = 0.dp, + top = 8.dp, + bottom = + if (isSelectionMode) 100.dp + else 16.dp // 🔥 Уменьшено для + // инпута + ), + reverseLayout = true ) { - // В reversed layout: дата показывается ПОСЛЕ сообщения - // (визуально СВЕРХУ группы сообщений) - if (showDate) { - DateHeader( - dateText = getDateText(message.timestamp.time), - secondaryTextColor = secondaryTextColor - ) - } - // 🔥 Ключ для выделения - используем только ID (как и в key списка) - val selectionKey = message.id - MessageBubble( - message = message, - isDarkTheme = isDarkTheme, - showTail = showTail, - isSelected = selectedMessages.contains(selectionKey), - isHighlighted = highlightedMessageId == message.id, - onLongClick = { - - // 🔥 СНАЧАЛА закрываем клавиатуру МГНОВЕННО (до изменения state) - if (!isSelectionMode) { - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() - showEmojiPicker = false - } - // Toggle selection on long press - selectedMessages = if (selectedMessages.contains(selectionKey)) { - selectedMessages - selectionKey - } else { - selectedMessages + selectionKey - } - - }, - onClick = { - // If in selection mode, toggle selection - if (isSelectionMode) { - selectedMessages = if (selectedMessages.contains(selectionKey)) { - selectedMessages - selectionKey - } else { - selectedMessages + selectionKey - } - } - }, - onSwipeToReply = { - // 🔥 Swipe-to-reply: добавляем это сообщение в reply - viewModel.setReplyMessages(listOf(message)) - }, - onReplyClick = { messageId -> - // 🔥 Клик на цитату - скроллим к сообщению - scrollToMessage(messageId) - }, - onRetry = { - // 🔥 Retry: удаляем старое и отправляем заново - viewModel.retryMessage(message) - }, - onDelete = { - // 🔥 Delete: удаляем сообщение - viewModel.deleteMessage(message.id) + // Reversed layout: item 0 = самое новое сообщение (внизу + // экрана) + // messagesWithDates уже отсортирован новые->старые + // 🔥 Используем только id как ключ - без index, чтобы избежать + // прыгания при добавлении новых сообщений + itemsIndexed( + messagesWithDates, + key = { _, item -> item.first.id } + ) { index, (message, showDate) -> + // Определяем, показывать ли хвостик (последнее сообщение в + // группе) + val nextMessage = + messagesWithDates.getOrNull(index + 1)?.first + val showTail = + nextMessage == null || + nextMessage.isOutgoing != + message.isOutgoing || + (message.timestamp.time - + nextMessage.timestamp.time) > + 60_000 // 1 минута + + // 🚀 ОПТИМИЗАЦИЯ: animateItemPlacement() для плавной + // анимации при добавлении/удалении + // Это предотвращает "прыжки" пузырьков при изменении списка + Column( + modifier = + Modifier.animateItemPlacement( + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioMediumBouncy, + stiffness = + Spring.StiffnessMedium + ) + ) + ) { + // В reversed layout: дата показывается ПОСЛЕ сообщения + // (визуально СВЕРХУ группы сообщений) + if (showDate) { + DateHeader( + dateText = + getDateText(message.timestamp.time), + secondaryTextColor = secondaryTextColor + ) } - ) + // 🔥 Ключ для выделения - используем только ID (как и в + // key списка) + val selectionKey = message.id + MessageBubble( + message = message, + isDarkTheme = isDarkTheme, + showTail = showTail, + isSelected = + selectedMessages.contains(selectionKey), + isHighlighted = + highlightedMessageId == message.id, + onLongClick = { + + // 🔥 СНАЧАЛА закрываем клавиатуру МГНОВЕННО + // (до изменения state) + if (!isSelectionMode) { + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager.clearFocus() + showEmojiPicker = false + } + // Toggle selection on long press + selectedMessages = + if (selectedMessages.contains( + selectionKey + ) + ) { + selectedMessages - selectionKey + } else { + selectedMessages + selectionKey + } + }, + onClick = { + // If in selection mode, toggle selection + if (isSelectionMode) { + selectedMessages = + if (selectedMessages.contains( + selectionKey + ) + ) { + selectedMessages - + selectionKey + } else { + selectedMessages + + selectionKey + } + } + }, + onSwipeToReply = { + // 🔥 Swipe-to-reply: добавляем это + // сообщение в reply + viewModel.setReplyMessages(listOf(message)) + }, + onReplyClick = { messageId -> + // 🔥 Клик на цитату - скроллим к сообщению + scrollToMessage(messageId) + }, + onRetry = { + // 🔥 Retry: удаляем старое и отправляем + // заново + viewModel.retryMessage(message) + }, + onDelete = { + // 🔥 Delete: удаляем сообщение + viewModel.deleteMessage(message.id) + } + ) + } + } } - } - } } } } } } // Закрытие Box с fade-in - // Диалог подтверждения удаления чата if (showDeleteConfirm) { AlertDialog( onDismissRequest = { showDeleteConfirm = false }, containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, - title = { - Text( - "Delete Chat", - fontWeight = FontWeight.Bold, - color = textColor - ) - }, + title = { Text("Delete Chat", fontWeight = FontWeight.Bold, color = textColor) }, text = { Text( "Are you sure you want to delete this chat? This action cannot be undone.", @@ -1287,67 +1447,125 @@ fun ChatDetailScreen( showDeleteConfirm = false scope.launch { try { - android.util.Log.d("ChatDetail", "🗑️ ========== DELETE CHAT START ==========") - android.util.Log.d("ChatDetail", "🗑️ currentUserPublicKey=${currentUserPublicKey}") - android.util.Log.d("ChatDetail", "🗑️ user.publicKey=${user.publicKey}") - - // Вычисляем правильный dialog_key (отсортированная комбинация ключей) - val dialogKey = if (currentUserPublicKey < user.publicKey) { - "$currentUserPublicKey:${user.publicKey}" - } else { - "${user.publicKey}:$currentUserPublicKey" - } + android.util.Log.d( + "ChatDetail", + "🗑️ ========== DELETE CHAT START ==========" + ) + android.util.Log.d( + "ChatDetail", + "🗑️ currentUserPublicKey=${currentUserPublicKey}" + ) + android.util.Log.d( + "ChatDetail", + "🗑️ user.publicKey=${user.publicKey}" + ) + + // Вычисляем правильный dialog_key (отсортированная + // комбинация ключей) + val dialogKey = + if (currentUserPublicKey < user.publicKey) { + "$currentUserPublicKey:${user.publicKey}" + } else { + "${user.publicKey}:$currentUserPublicKey" + } android.util.Log.d("ChatDetail", "🗑️ dialogKey=$dialogKey") - + // 🗑️ Очищаем ВСЕ кэши сообщений - com.rosetta.messenger.data.MessageRepository.getInstance(context).clearDialogCache(user.publicKey) + com.rosetta.messenger.data.MessageRepository.getInstance( + context + ) + .clearDialogCache(user.publicKey) // 🗑️ Очищаем кэш ChatViewModel ChatViewModel.clearCacheForOpponent(user.publicKey) - + // Проверяем количество сообщений до удаления - val countBefore = database.messageDao().getMessageCount(currentUserPublicKey, dialogKey) - android.util.Log.d("ChatDetail", "🗑️ Messages BEFORE delete: $countBefore") - + val countBefore = + database.messageDao() + .getMessageCount( + currentUserPublicKey, + dialogKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Messages BEFORE delete: $countBefore" + ) + // Удаляем все сообщения из диалога по dialog_key - val deletedByKey = database.messageDao().deleteDialog( - account = currentUserPublicKey, - dialogKey = dialogKey + val deletedByKey = + database.messageDao() + .deleteDialog( + account = currentUserPublicKey, + dialogKey = dialogKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Deleted by dialogKey: $deletedByKey" ) - android.util.Log.d("ChatDetail", "🗑️ Deleted by dialogKey: $deletedByKey") - - // Также пробуем удалить по from/to ключам (на всякий случай) - val deletedBetween = database.messageDao().deleteMessagesBetweenUsers( - account = currentUserPublicKey, - user1 = user.publicKey, - user2 = currentUserPublicKey + + // Также пробуем удалить по from/to ключам (на всякий + // случай) + val deletedBetween = + database.messageDao() + .deleteMessagesBetweenUsers( + account = currentUserPublicKey, + user1 = user.publicKey, + user2 = currentUserPublicKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Deleted between users: $deletedBetween" ) - android.util.Log.d("ChatDetail", "🗑️ Deleted between users: $deletedBetween") - + // Проверяем количество сообщений после удаления - val countAfter = database.messageDao().getMessageCount(currentUserPublicKey, dialogKey) - android.util.Log.d("ChatDetail", "🗑️ Messages AFTER delete: $countAfter") - - // Очищаем кеш диалога - database.dialogDao().deleteDialog( - account = currentUserPublicKey, - opponentKey = user.publicKey + val countAfter = + database.messageDao() + .getMessageCount( + currentUserPublicKey, + dialogKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Messages AFTER delete: $countAfter" ) - android.util.Log.d("ChatDetail", "🗑️ Dialog deleted from DB") - + + // Очищаем кеш диалога + database.dialogDao() + .deleteDialog( + account = currentUserPublicKey, + opponentKey = user.publicKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Dialog deleted from DB" + ) + // Проверяем что диалог удален - val dialogAfter = database.dialogDao().getDialog(currentUserPublicKey, user.publicKey) - android.util.Log.d("ChatDetail", "🗑️ Dialog after: ${dialogAfter?.opponentKey ?: "NULL (deleted)"}") - android.util.Log.d("ChatDetail", "🗑️ ========== DELETE CHAT COMPLETE ==========") + val dialogAfter = + database.dialogDao() + .getDialog( + currentUserPublicKey, + user.publicKey + ) + android.util.Log.d( + "ChatDetail", + "🗑️ Dialog after: ${dialogAfter?.opponentKey ?: "NULL (deleted)"}" + ) + android.util.Log.d( + "ChatDetail", + "🗑️ ========== DELETE CHAT COMPLETE ==========" + ) } catch (e: Exception) { - android.util.Log.e("ChatDetail", "🗑️ DELETE ERROR: ${e.message}", e) + android.util.Log.e( + "ChatDetail", + "🗑️ DELETE ERROR: ${e.message}", + e + ) } // Выходим ПОСЛЕ удаления onBack() } } - ) { - Text("Delete", color = Color(0xFFFF3B30)) - } + ) { Text("Delete", color = Color(0xFFFF3B30)) } }, dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { @@ -1356,7 +1574,7 @@ fun ChatDetailScreen( } ) } - + // Диалог подтверждения блокировки if (showBlockConfirm) { AlertDialog( @@ -1382,21 +1600,22 @@ fun ChatDetailScreen( scope.launch { try { // Добавляем пользователя в blacklist - database.blacklistDao().blockUser( - com.rosetta.messenger.database.BlacklistEntity( - publicKey = user.publicKey, - account = currentUserPublicKey - ) - ) + database.blacklistDao() + .blockUser( + com.rosetta.messenger.database + .BlacklistEntity( + publicKey = user.publicKey, + account = + currentUserPublicKey + ) + ) isBlocked = true } catch (e: Exception) { // Error blocking user } } } - ) { - Text("Block", color = Color(0xFFFF3B30)) - } + ) { Text("Block", color = Color(0xFFFF3B30)) } }, dismissButton = { TextButton(onClick = { showBlockConfirm = false }) { @@ -1405,7 +1624,7 @@ fun ChatDetailScreen( } ) } - + // Диалог подтверждения разблокировки if (showUnblockConfirm) { AlertDialog( @@ -1431,19 +1650,18 @@ fun ChatDetailScreen( scope.launch { try { // Удаляем пользователя из blacklist - database.blacklistDao().unblockUser( - publicKey = user.publicKey, - account = currentUserPublicKey - ) + database.blacklistDao() + .unblockUser( + publicKey = user.publicKey, + account = currentUserPublicKey + ) isBlocked = false } catch (e: Exception) { // Error unblocking user } } } - ) { - Text("Unblock", color = PrimaryBlue) - } + ) { Text("Unblock", color = PrimaryBlue) } }, dismissButton = { TextButton(onClick = { showUnblockConfirm = false }) { @@ -1452,86 +1670,90 @@ fun ChatDetailScreen( } ) } - + // � Bottom Sheet меню (вместо popup menu) if (showMenu) { ModalBottomSheet( - onDismissRequest = { showMenu = false }, - containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, - shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), - scrimColor = Color.Black.copy(alpha = 0.6f), - windowInsets = WindowInsets(0, 0, 0, 0), // Перекрываем весь экран включая status bar - dragHandle = { - Box( - modifier = Modifier - .padding(vertical = 12.dp) - .width(36.dp) - .height(4.dp) - .clip(RoundedCornerShape(2.dp)) - .background(if (isDarkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.2f)) - ) - } + onDismissRequest = { showMenu = false }, + containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White, + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + scrimColor = Color.Black.copy(alpha = 0.6f), + windowInsets = + WindowInsets(0, 0, 0, 0), // Перекрываем весь экран включая status bar + dragHandle = { + Box( + modifier = + Modifier.padding(vertical = 12.dp) + .width(36.dp) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.3f) + else Color.Black.copy(alpha = 0.2f) + ) + ) + } ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 32.dp) - ) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 32.dp)) { // Block/Unblock User if (!isSavedMessages) { BottomSheetMenuItem( - icon = if (isBlocked) Icons.Default.CheckCircle else Icons.Default.Block, - text = if (isBlocked) "Unblock User" else "Block User", - onClick = { - showMenu = false - if (isBlocked) { - showUnblockConfirm = true - } else { - showBlockConfirm = true - } - }, - isDarkTheme = isDarkTheme, - tintColor = PrimaryBlue + icon = + if (isBlocked) Icons.Default.CheckCircle + else Icons.Default.Block, + text = if (isBlocked) "Unblock User" else "Block User", + onClick = { + showMenu = false + if (isBlocked) { + showUnblockConfirm = true + } else { + showBlockConfirm = true + } + }, + isDarkTheme = isDarkTheme, + tintColor = PrimaryBlue ) - + Divider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 0.5.dp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f) + modifier = Modifier.padding(horizontal = 16.dp), + thickness = 0.5.dp, + color = + if (isDarkTheme) Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f) ) } - + // Delete Chat BottomSheetMenuItem( - icon = Icons.Default.Delete, - text = "Delete Chat", - onClick = { - showMenu = false - showDeleteConfirm = true - }, - isDarkTheme = isDarkTheme, - isDestructive = true + icon = Icons.Default.Delete, + text = "Delete Chat", + onClick = { + showMenu = false + showDeleteConfirm = true + }, + isDarkTheme = isDarkTheme, + isDestructive = true ) } } } - + // �📨 Forward Chat Picker BottomSheet if (showForwardPicker) { ForwardChatPickerBottomSheet( - dialogs = dialogsList, - isDarkTheme = isDarkTheme, - currentUserPublicKey = currentUserPublicKey, - onDismiss = { - showForwardPicker = false - ForwardManager.clear() - }, - onChatSelected = { selectedPublicKey -> - showForwardPicker = false - // Переходим в выбранный чат - ForwardManager.selectChat(selectedPublicKey) - onNavigateToChat(selectedPublicKey) - } + dialogs = dialogsList, + isDarkTheme = isDarkTheme, + currentUserPublicKey = currentUserPublicKey, + onDismiss = { + showForwardPicker = false + ForwardManager.clear() + }, + onChatSelected = { selectedPublicKey -> + showForwardPicker = false + // Переходим в выбранный чат + ForwardManager.selectChat(selectedPublicKey) + onNavigateToChat(selectedPublicKey) + } ) } } @@ -1567,466 +1789,500 @@ fun rememberMessageEnterAnimation(messageId: String): Pair { @OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageBubble( - message: ChatMessage, - isDarkTheme: Boolean, - showTail: Boolean = true, - isSelected: Boolean = false, - isHighlighted: Boolean = false, // 🔥 Подсветка при клике на reply - onLongClick: () -> Unit = {}, - onClick: () -> Unit = {}, - onSwipeToReply: () -> Unit = {}, - onReplyClick: (String) -> Unit = {}, // 🔥 Клик на цитату - скролл к сообщению - onRetry: () -> Unit = {}, // 🔥 Retry для ошибки - onDelete: () -> Unit = {} // 🔥 Delete для ошибки + message: ChatMessage, + isDarkTheme: Boolean, + showTail: Boolean = true, + isSelected: Boolean = false, + isHighlighted: Boolean = false, // 🔥 Подсветка при клике на reply + onLongClick: () -> Unit = {}, + onClick: () -> Unit = {}, + onSwipeToReply: () -> Unit = {}, + onReplyClick: (String) -> Unit = {}, // 🔥 Клик на цитату - скролл к сообщению + onRetry: () -> Unit = {}, // 🔥 Retry для ошибки + onDelete: () -> Unit = {} // 🔥 Delete для ошибки ) { // 🔥 Swipe-to-reply state (как в Telegram) var swipeOffset by remember { mutableStateOf(0f) } val swipeThreshold = 80f // dp порог для активации reply val maxSwipe = 120f // Максимальный сдвиг - + // Анимация возврата - val animatedOffset by animateFloatAsState( - targetValue = swipeOffset, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), - label = "swipeOffset" - ) - + val animatedOffset by + animateFloatAsState( + targetValue = swipeOffset, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "swipeOffset" + ) + // Прогресс свайпа для иконки reply (0..1) - используем абсолютное значение т.к. свайп влево val swipeProgress = (kotlin.math.abs(animatedOffset) / swipeThreshold).coerceIn(0f, 1f) - + // Selection animation - только если нужно - val selectionScale by animateFloatAsState( - targetValue = if (isSelected) 0.95f else 1f, - animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), - label = "selectionScale" - ) - val selectionAlpha by animateFloatAsState( - targetValue = if (isSelected) 0.85f else 1f, - animationSpec = tween(150), - label = "selectionAlpha" - ) + val selectionScale by + animateFloatAsState( + targetValue = if (isSelected) 0.95f else 1f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 400f), + label = "selectionScale" + ) + val selectionAlpha by + animateFloatAsState( + targetValue = if (isSelected) 0.85f else 1f, + animationSpec = tween(150), + label = "selectionAlpha" + ) // 🔥 Цвета - НАШИ ОРИГИНАЛЬНЫЕ - val bubbleColor = remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) { - PrimaryBlue // Исходящие - наш синий - } else { - // Входящие - наши цвета - if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) - } - } - val textColor = remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White - else if (isDarkTheme) Color.White else Color(0xFF000000) - } + val bubbleColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) { + PrimaryBlue // Исходящие - наш синий + } else { + // Входящие - наши цвета + if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5) + } + } + val textColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White + else if (isDarkTheme) Color.White else Color(0xFF000000) + } // Время - наши оригинальные цвета - val timeColor = remember(message.isOutgoing, isDarkTheme) { - if (message.isOutgoing) Color.White.copy(alpha = 0.7f) - else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - } + val timeColor = + remember(message.isOutgoing, isDarkTheme) { + if (message.isOutgoing) Color.White.copy(alpha = 0.7f) + else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + } // 🔥 TELEGRAM STYLE: Форма пузырька - более мягкие углы - val bubbleShape = remember(message.isOutgoing, showTail) { - RoundedCornerShape( - topStart = 16.dp, - topEnd = 16.dp, - // Хвостик: маленький радиус (4dp) только у нижнего угла со стороны отправителя - bottomStart = if (message.isOutgoing) 16.dp else (if (showTail) 4.dp else 16.dp), - bottomEnd = if (message.isOutgoing) (if (showTail) 4.dp else 16.dp) else 16.dp - ) - } + val bubbleShape = + remember(message.isOutgoing, showTail) { + RoundedCornerShape( + topStart = 16.dp, + topEnd = 16.dp, + // Хвостик: маленький радиус (4dp) только у нижнего угла со стороны + // отправителя + bottomStart = + if (message.isOutgoing) 16.dp else (if (showTail) 4.dp else 16.dp), + bottomEnd = + if (message.isOutgoing) (if (showTail) 4.dp else 16.dp) else 16.dp + ) + } // 🔥 ОПТИМИЗАЦИЯ: SimpleDateFormat создаём один раз val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) } - + // 🔥 Swipe-to-reply wrapper Box( - modifier = Modifier - .fillMaxWidth() - .pointerInput(Unit) { - detectHorizontalDragGestures( - onDragEnd = { - // Если свайп достиг порога - активируем reply - if (swipeOffset <= -swipeThreshold) { - onSwipeToReply() - } - // Возвращаем на место - swipeOffset = 0f - }, - onDragCancel = { - swipeOffset = 0f - }, - onHorizontalDrag = { _, dragAmount -> - // Только свайп влево (отрицательный dragAmount) - val newOffset = swipeOffset + dragAmount - swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) + modifier = + Modifier.fillMaxWidth().pointerInput(Unit) { + detectHorizontalDragGestures( + onDragEnd = { + // Если свайп достиг порога - активируем reply + if (swipeOffset <= -swipeThreshold) { + onSwipeToReply() + } + // Возвращаем на место + swipeOffset = 0f + }, + onDragCancel = { swipeOffset = 0f }, + onHorizontalDrag = { _, dragAmount -> + // Только свайп влево (отрицательный dragAmount) + val newOffset = swipeOffset + dragAmount + swipeOffset = newOffset.coerceIn(-maxSwipe, 0f) + } + ) } - ) - } ) { // 🔥 Reply icon (появляется справа при свайпе влево) Box( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 16.dp) - .graphicsLayer { - alpha = swipeProgress - scaleX = swipeProgress - scaleY = swipeProgress - } + modifier = + Modifier.align(Alignment.CenterEnd).padding(end = 16.dp).graphicsLayer { + alpha = swipeProgress + scaleX = swipeProgress + scaleY = swipeProgress + } ) { Box( - modifier = Modifier - .size(36.dp) - .clip(CircleShape) - .background( - if (swipeProgress >= 1f) PrimaryBlue - else if (isDarkTheme) Color(0xFF3A3A3A) - else Color(0xFFE0E0E0) - ), - contentAlignment = Alignment.Center + modifier = + Modifier.size(36.dp) + .clip(CircleShape) + .background( + if (swipeProgress >= 1f) PrimaryBlue + else if (isDarkTheme) Color(0xFF3A3A3A) + else Color(0xFFE0E0E0) + ), + contentAlignment = Alignment.Center ) { Icon( - Icons.Default.Reply, - contentDescription = "Reply", - tint = if (swipeProgress >= 1f) Color.White - else if (isDarkTheme) Color.White.copy(alpha = 0.7f) - else Color(0xFF666666), - modifier = Modifier.size(20.dp) + Icons.Default.Reply, + contentDescription = "Reply", + tint = + if (swipeProgress >= 1f) Color.White + else if (isDarkTheme) Color.White.copy(alpha = 0.7f) + else Color(0xFF666666), + modifier = Modifier.size(20.dp) ) } } // 🔥 TELEGRAM STYLE: Полупрозрачный синий фон для выбранных сообщений - val selectionBackgroundColor by animateColorAsState( - targetValue = if (isSelected) PrimaryBlue.copy(alpha = 0.15f) else Color.Transparent, - animationSpec = tween(200), - label = "selectionBg" - ) - - // 🔥 Подсветка для highlighted сообщений (клик на reply) - голубой прозрачный - val highlightBackgroundColor by animateColorAsState( - targetValue = if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) else Color.Transparent, - animationSpec = tween(300), - label = "highlightBg" - ) - - // 🔥 Комбинируем оба фона (selection имеет приоритет) - val combinedBackgroundColor = if (isSelected) selectionBackgroundColor else highlightBackgroundColor - - Row( - modifier = - Modifier.fillMaxWidth() - // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину экрана! - .background(combinedBackgroundColor) - // 🔥 Только vertical padding, horizontal убран чтобы выделение было edge-to-edge - .padding(vertical = 2.dp) - .offset { IntOffset(animatedOffset.toInt(), 0) }, - horizontalArrangement = Arrangement.Start, - verticalAlignment = Alignment.CenterVertically - ) { - // 🔥 TELEGRAM STYLE: Зеленая галочка СЛЕВА (как в Telegram) - AnimatedVisibility( - visible = isSelected, - enter = fadeIn(tween(150)) + scaleIn(initialScale = 0.3f, animationSpec = spring(dampingRatio = 0.6f)), - exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) - ) { - Box( - modifier = Modifier - .padding(start = 12.dp, end = 4.dp) - .size(24.dp) - .clip(CircleShape) - .background(Color(0xFF4CD964)), // Зеленый как в Telegram - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Check, - contentDescription = "Selected", - tint = Color.White, - modifier = Modifier.size(16.dp) + val selectionBackgroundColor by + animateColorAsState( + targetValue = + if (isSelected) PrimaryBlue.copy(alpha = 0.15f) + else Color.Transparent, + animationSpec = tween(200), + label = "selectionBg" ) - } - } - - // Spacer для невыбранных сообщений (чтобы пузырьки не прыгали) - AnimatedVisibility( - visible = !isSelected, - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)) - ) { - Spacer(modifier = Modifier.width(12.dp)) // Отступ слева когда нет галочки - } - - // 🔥 Spacer для выравнивания исходящих сообщений вправо - if (message.isOutgoing) { - Spacer(modifier = Modifier.weight(1f)) - } - - Box( + + // 🔥 Подсветка для highlighted сообщений (клик на reply) - голубой прозрачный + val highlightBackgroundColor by + animateColorAsState( + targetValue = + if (isHighlighted) PrimaryBlue.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(300), + label = "highlightBg" + ) + + // 🔥 Комбинируем оба фона (selection имеет приоритет) + val combinedBackgroundColor = + if (isSelected) selectionBackgroundColor else highlightBackgroundColor + + Row( modifier = - Modifier - // 🔥 Добавляем горизонтальные отступы к пузырьку - .padding(end = 12.dp) - .widthIn(max = 280.dp) // 🔥 TELEGRAM: чуть уже (280dp) - .graphicsLayer { - this.alpha = selectionAlpha - this.scaleX = selectionScale - this.scaleY = selectionScale - } - .combinedClickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onClick, - onLongClick = onLongClick - ) - .clip(bubbleShape) - .background(bubbleColor) - // 🔥 TELEGRAM: padding 10-12dp horizontal, 8dp vertical - .padding(horizontal = 10.dp, vertical = 8.dp) + Modifier.fillMaxWidth() + // 🔥 TELEGRAM: Фоновая подсветка при выделении - на всю ширину + // экрана! + .background(combinedBackgroundColor) + // 🔥 Только vertical padding, horizontal убран чтобы выделение было + // edge-to-edge + .padding(vertical = 2.dp) + .offset { IntOffset(animatedOffset.toInt(), 0) }, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically ) { - // 🔥 TELEGRAM STYLE: текст и время на одной строке - Column { - // Reply bubble (цитата) - message.replyData?.let { reply -> - ReplyBubble( - replyData = reply, - isOutgoing = message.isOutgoing, - isDarkTheme = isDarkTheme, - onClick = { - onReplyClick(reply.messageId) - } - ) - Spacer(modifier = Modifier.height(4.dp)) // Меньше отступ - } - - // Текст и время в одной строке (Row) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(10.dp) // Увеличенный отступ до времени + // 🔥 TELEGRAM STYLE: Зеленая галочка СЛЕВА (как в Telegram) + AnimatedVisibility( + visible = isSelected, + enter = + fadeIn(tween(150)) + + scaleIn( + initialScale = 0.3f, + animationSpec = spring(dampingRatio = 0.6f) + ), + exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f) + ) { + Box( + modifier = + Modifier.padding(start = 12.dp, end = 4.dp) + .size(24.dp) + .clip(CircleShape) + .background(Color(0xFF4CD964)), // Зеленый как в Telegram + contentAlignment = Alignment.Center ) { - // 🔥 TELEGRAM: Текст 17sp, lineHeight 22sp, letterSpacing -0.4sp - AppleEmojiText( - text = message.text, - color = textColor, - fontSize = 17.sp, - modifier = Modifier.weight(1f, fill = false) + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = Color.White, + modifier = Modifier.size(16.dp) ) - - // Время и статус справа - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp), - modifier = Modifier.padding(bottom = 2.dp) - ) { - // 🔥 TELEGRAM: Время 11sp, italic style - Text( - text = timeFormat.format(message.timestamp), - color = timeColor, - fontSize = 11.sp, - fontStyle = androidx.compose.ui.text.font.FontStyle.Italic + } + } + + // Spacer для невыбранных сообщений (чтобы пузырьки не прыгали) + AnimatedVisibility( + visible = !isSelected, + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { + Spacer(modifier = Modifier.width(12.dp)) // Отступ слева когда нет галочки + } + + // 🔥 Spacer для выравнивания исходящих сообщений вправо + if (message.isOutgoing) { + Spacer(modifier = Modifier.weight(1f)) + } + + Box( + modifier = + Modifier + // 🔥 Добавляем горизонтальные отступы к пузырьку + .padding(end = 12.dp) + .widthIn(max = 280.dp) // 🔥 TELEGRAM: чуть уже (280dp) + .graphicsLayer { + this.alpha = selectionAlpha + this.scaleX = selectionScale + this.scaleY = selectionScale + } + .combinedClickable( + indication = null, + interactionSource = + remember { MutableInteractionSource() }, + onClick = onClick, + onLongClick = onLongClick + ) + .clip(bubbleShape) + .background(bubbleColor) + // 🔥 TELEGRAM: padding 10-12dp horizontal, 8dp vertical + .padding(horizontal = 10.dp, vertical = 8.dp) + ) { + // 🔥 TELEGRAM STYLE: текст и время на одной строке + Column { + // Reply bubble (цитата) + message.replyData?.let { reply -> + ReplyBubble( + replyData = reply, + isOutgoing = message.isOutgoing, + isDarkTheme = isDarkTheme, + onClick = { onReplyClick(reply.messageId) } ) - if (message.isOutgoing) { - AnimatedMessageStatus( - status = message.status, - timeColor = timeColor, - timestamp = message.timestamp.time, - onRetry = onRetry, - onDelete = onDelete + Spacer(modifier = Modifier.height(4.dp)) // Меньше отступ + } + + // Текст и время в одной строке (Row) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = + Arrangement.spacedBy(10.dp) // Увеличенный отступ до времени + ) { + // 🔥 TELEGRAM: Текст 17sp, lineHeight 22sp, letterSpacing -0.4sp + AppleEmojiText( + text = message.text, + color = textColor, + fontSize = 17.sp, + modifier = Modifier.weight(1f, fill = false) + ) + + // Время и статус справа + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.padding(bottom = 2.dp) + ) { + // 🔥 TELEGRAM: Время 11sp, italic style + Text( + text = timeFormat.format(message.timestamp), + color = timeColor, + fontSize = 11.sp, + fontStyle = androidx.compose.ui.text.font.FontStyle.Italic ) + if (message.isOutgoing) { + AnimatedMessageStatus( + status = message.status, + timeColor = timeColor, + timestamp = message.timestamp.time, + onRetry = onRetry, + onDelete = onDelete + ) + } } } } } } - } } // End of swipe Box wrapper } /** - * 🎯 Анимированный статус сообщения с плавными переходами - * Поддерживает ERROR статус с красной иконкой (как в архиве) + * 🎯 Анимированный статус сообщения с плавными переходами Поддерживает ERROR статус с красной + * иконкой (как в архиве) */ @Composable private fun AnimatedMessageStatus( - status: MessageStatus, - timeColor: Color, - timestamp: Long = 0L, - onRetry: () -> Unit = {}, - onDelete: () -> Unit = {} + status: MessageStatus, + timeColor: Color, + timestamp: Long = 0L, + onRetry: () -> Unit = {}, + onDelete: () -> Unit = {} ) { // 🔥 Проверяем таймаут для SENDING статуса - val isTimedOut = status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp) + val isTimedOut = + status == MessageStatus.SENDING && timestamp > 0 && !isMessageDeliveredByTime(timestamp) val effectiveStatus = if (isTimedOut) MessageStatus.ERROR else status - + // Цвет с анимацией - val targetColor = when (effectiveStatus) { - MessageStatus.READ -> Color(0xFF4FC3F7) // Синий для прочитано - MessageStatus.ERROR -> Color(0xFFE53935) // Красный для ошибки - else -> timeColor - } - val animatedColor by animateColorAsState( - targetValue = targetColor, - animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), - label = "statusColor" - ) - + val targetColor = + when (effectiveStatus) { + MessageStatus.READ -> Color(0xFF4FC3F7) // Синий для прочитано + MessageStatus.ERROR -> Color(0xFFE53935) // Красный для ошибки + else -> timeColor + } + val animatedColor by + animateColorAsState( + targetValue = targetColor, + animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing), + label = "statusColor" + ) + // Анимация scale для эффекта "pop" var previousStatus by remember { mutableStateOf(effectiveStatus) } var shouldAnimate by remember { mutableStateOf(false) } - + LaunchedEffect(effectiveStatus) { if (previousStatus != effectiveStatus) { shouldAnimate = true previousStatus = effectiveStatus } } - - val scale by animateFloatAsState( - targetValue = if (shouldAnimate) 1.2f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - finishedListener = { shouldAnimate = false }, - label = "statusScale" - ) - + + val scale by + animateFloatAsState( + targetValue = if (shouldAnimate) 1.2f else 1f, + animationSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + finishedListener = { shouldAnimate = false }, + label = "statusScale" + ) + // 🔥 Для ошибки - показываем DropdownMenu var showErrorMenu by remember { mutableStateOf(false) } - + Box { // Crossfade для плавной смены иконки Crossfade( - targetState = effectiveStatus, - animationSpec = tween(durationMillis = 200), - label = "statusIcon" + targetState = effectiveStatus, + animationSpec = tween(durationMillis = 200), + label = "statusIcon" ) { currentStatus -> Icon( - imageVector = when (currentStatus) { - MessageStatus.SENDING -> Icons.Default.Schedule // Часики - отправляется - MessageStatus.SENT -> Icons.Default.Done // Одна галочка - отправлено - MessageStatus.DELIVERED -> Icons.Default.Done // Одна галочка - доставлено - MessageStatus.READ -> Icons.Default.DoneAll // Две галочки - прочитано - MessageStatus.ERROR -> Icons.Default.Error // Ошибка - восклицательный знак - }, - contentDescription = null, - tint = animatedColor, - modifier = Modifier - .size(16.dp) - .scale(scale) - .then( - if (currentStatus == MessageStatus.ERROR) { - Modifier.clickable { showErrorMenu = true } - } else Modifier - ) + imageVector = + when (currentStatus) { + MessageStatus.SENDING -> + Icons.Default.Schedule // Часики - отправляется + MessageStatus.SENT -> + Icons.Default.Done // Одна галочка - отправлено + MessageStatus.DELIVERED -> + Icons.Default.Done // Одна галочка - доставлено + MessageStatus.READ -> + Icons.Default.DoneAll // Две галочки - прочитано + MessageStatus.ERROR -> + Icons.Default.Error // Ошибка - восклицательный знак + }, + contentDescription = null, + tint = animatedColor, + modifier = + Modifier.size(16.dp) + .scale(scale) + .then( + if (currentStatus == MessageStatus.ERROR) { + Modifier.clickable { showErrorMenu = true } + } else Modifier + ) ) } - + // 🔥 Меню ошибки (как в архиве) - DropdownMenu( - expanded = showErrorMenu, - onDismissRequest = { showErrorMenu = false } - ) { + DropdownMenu(expanded = showErrorMenu, onDismissRequest = { showErrorMenu = false }) { DropdownMenuItem( - text = { Text("Retry") }, - onClick = { - showErrorMenu = false - onRetry() - }, - leadingIcon = { - Icon(Icons.Default.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) - } + text = { Text("Retry") }, + onClick = { + showErrorMenu = false + onRetry() + }, + leadingIcon = { + Icon( + Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } ) DropdownMenuItem( - text = { Text("Delete", color = Color(0xFFE53935)) }, - onClick = { - showErrorMenu = false - onDelete() - }, - leadingIcon = { - Icon(Icons.Default.Delete, contentDescription = null, tint = Color(0xFFE53935), modifier = Modifier.size(18.dp)) - } + text = { Text("Delete", color = Color(0xFFE53935)) }, + onClick = { + showErrorMenu = false + onDelete() + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(18.dp) + ) + } ) } } } -/** - * 🔥 Reply bubble (цитата) внутри сообщения - * Стиль: вертикальная линия слева + имя + текст - */ +/** 🔥 Reply bubble (цитата) внутри сообщения Стиль: вертикальная линия слева + имя + текст */ @Composable private fun ReplyBubble( - replyData: ReplyData, - isOutgoing: Boolean, - isDarkTheme: Boolean, - onClick: () -> Unit = {} // 🔥 Клик на цитату + replyData: ReplyData, + isOutgoing: Boolean, + isDarkTheme: Boolean, + onClick: () -> Unit = {} // 🔥 Клик на цитату ) { // НАШИ ОРИГИНАЛЬНЫЕ ЦВЕТА - val backgroundColor = if (isOutgoing) { - Color.Black.copy(alpha = 0.15f) - } else { - Color.Black.copy(alpha = 0.08f) - } - - val borderColor = if (isOutgoing) { - Color.White - } else { - PrimaryBlue - } - - val nameColor = if (isOutgoing) { - Color.White - } else { - PrimaryBlue - } - - val replyTextColor = if (isOutgoing) { - Color.White.copy(alpha = 0.85f) - } else { - if (isDarkTheme) Color.White else Color.Black - } - + val backgroundColor = + if (isOutgoing) { + Color.Black.copy(alpha = 0.15f) + } else { + Color.Black.copy(alpha = 0.08f) + } + + val borderColor = + if (isOutgoing) { + Color.White + } else { + PrimaryBlue + } + + val nameColor = + if (isOutgoing) { + Color.White + } else { + PrimaryBlue + } + + val replyTextColor = + if (isOutgoing) { + Color.White.copy(alpha = 0.85f) + } else { + if (isDarkTheme) Color.White else Color.Black + } + Row( - modifier = Modifier - .wrapContentWidth() - .height(IntrinsicSize.Min) - .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = onClick) // 🔥 Клик на цитату - .background(backgroundColor) + modifier = + Modifier.wrapContentWidth() + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) // 🔥 Клик на цитату + .background(backgroundColor) ) { // 🔥 TELEGRAM: Вертикальная линия слева 3dp - Box( - modifier = Modifier - .width(3.dp) - .fillMaxHeight() - .background(borderColor) - ) - + Box(modifier = Modifier.width(3.dp).fillMaxHeight().background(borderColor)) + // Контент Column( - modifier = Modifier - // 🔥 TELEGRAM: padding как в дизайне - .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) - .widthIn(max = 220.dp) + modifier = + Modifier + // 🔥 TELEGRAM: padding как в дизайне + .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) + .widthIn(max = 220.dp) ) { // 🔥 TELEGRAM: Имя 14sp, Medium weight Text( - text = replyData.senderName, - color = nameColor, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = replyData.senderName, + color = nameColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - + // 🔥 TELEGRAM: Текст цитаты 14sp, Regular Text( - text = replyData.text.ifEmpty { "..." }, - color = replyTextColor, - fontSize = 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = replyData.text.ifEmpty { "..." }, + color = replyTextColor, + fontSize = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } @@ -2070,7 +2326,7 @@ private fun DateHeader(dateText: String, secondaryTextColor: Color) { /** * Панель ввода сообщения - Telegram UX канон - * + * * Золотые правила: * 1. Инпут всегда связан с клавиатурой (imePadding) * 2. Последнее сообщение всегда видно @@ -2107,21 +2363,23 @@ private fun MessageInputBar( onReplyClick: (String) -> Unit = {} ) { // 🔥 ОПТИМИЗАЦИЯ: Убрано логирование чтобы не засорять logcat - + val hasReply = replyMessages.isNotEmpty() val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } val scope = rememberCoroutineScope() - + // Получаем context и view для гарантированного закрытия клавиатуры val context = LocalContext.current val view = LocalView.current val density = LocalDensity.current - + // 🔥 Ссылка на EditText для программного фокуса - var editTextView by remember { mutableStateOf(null) } - + var editTextView by remember { + mutableStateOf(null) + } + // 🔥 Автофокус при открытии reply панели LaunchedEffect(hasReply, editTextView) { if (hasReply) { @@ -2132,49 +2390,51 @@ private fun MessageInputBar( if (!showEmojiPicker) { editText.requestFocus() // Открываем клавиатуру - val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + val imm = + context.getSystemService(Context.INPUT_METHOD_SERVICE) as + InputMethodManager imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT) - } else { - } + } else {} } } } // 🔥 ОПТИМИЗАЦИЯ: НЕ читаем imeHeight напрямую - это вызывает рекомпозицию! val imeInsets = WindowInsets.ime - + // 🔥 Флаг "клавиатура видна" - обновляется через snapshotFlow, НЕ вызывает рекомпозицию var isKeyboardVisible by remember { mutableStateOf(false) } var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) } - + // � Защита от слишком частого переключения клавиатуры (300ms cooldown) var lastToggleTime by remember { mutableLongStateOf(0L) } val toggleCooldownMs = 500L - + // 🚫 Флаг "клавиатура анимируется" - блокирует переключение пока высота не стабилизируется var isKeyboardAnimating by remember { mutableStateOf(false) } var lastKeyboardHeightChange by remember { mutableLongStateOf(0L) } val keyboardAnimationStabilizeMs = 250L // Ждем стабилизации 250ms // �🔥 Обновляем coordinator через snapshotFlow (БЕЗ рекомпозиции!) LaunchedEffect(Unit) { - snapshotFlow { - with(density) { imeInsets.getBottom(density).toDp() } - }.collect { currentImeHeight -> + snapshotFlow { with(density) { imeInsets.getBottom(density).toDp() } }.collect { + currentImeHeight -> // 🚫 Отслеживаем изменения высоты - если меняется, клава анимируется val now = System.currentTimeMillis() - val heightChanged = kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f + val heightChanged = + kotlin.math.abs((currentImeHeight - lastStableKeyboardHeight).value) > 5f if (heightChanged && currentImeHeight.value > 0) { lastKeyboardHeightChange = now isKeyboardAnimating = true } // Если высота не менялась > 250ms - анимация завершена - if (now - lastKeyboardHeightChange > keyboardAnimationStabilizeMs && isKeyboardAnimating) { + if (now - lastKeyboardHeightChange > keyboardAnimationStabilizeMs && isKeyboardAnimating + ) { isKeyboardAnimating = false } - + // Обновляем флаг видимости (это НЕ вызывает рекомпозицию напрямую) isKeyboardVisible = currentImeHeight > 50.dp - + // Обновляем coordinator coordinator.updateKeyboardHeight(currentImeHeight) if (currentImeHeight > 100.dp) { @@ -2183,37 +2443,40 @@ private fun MessageInputBar( } } } - + // 🔥 Запоминаем высоту клавиатуры когда она открыта (Telegram-style with SharedPreferences) LaunchedEffect(Unit) { // Загружаем сохранённую высоту при старте com.rosetta.messenger.ui.components.KeyboardHeightProvider.getSavedKeyboardHeight(context) } - - // 🔥 Сохраняем высоту ТОЛЬКО когда клавиатура стабильна + + // 🔥 Сохраняем высоту ТОЛЬКО когда клавиатура стабильна LaunchedEffect(isKeyboardVisible, showEmojiPicker) { // Если клавиатура стала видимой и emoji закрыт if (isKeyboardVisible && !showEmojiPicker) { // Ждем стабилизации kotlinx.coroutines.delay(350) // Анимация клавиатуры ~300ms - + // Сохраняем только если всё еще видна и emoji закрыт if (isKeyboardVisible && !showEmojiPicker && lastStableKeyboardHeight > 300.dp) { val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() } - com.rosetta.messenger.ui.components.KeyboardHeightProvider.saveKeyboardHeight(context, heightPx) + com.rosetta.messenger.ui.components.KeyboardHeightProvider.saveKeyboardHeight( + context, + heightPx + ) } } } // Состояние отправки - можно отправить если есть текст ИЛИ есть reply val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } - + // 🔥 Флаг отправки - предотвращает исчезновение кнопки Send во время отправки reply var isSending by remember { mutableStateOf(false) } - + // 🔥 УДАЛЕНО: автоматическое закрытие emoji при открытии клавиатуры // Теперь это контролируется только через toggleEmojiPicker() - + // 🔥 Закрываем клавиатуру когда пользователь заблокирован LaunchedEffect(isBlocked) { if (isBlocked) { @@ -2235,43 +2498,37 @@ private fun MessageInputBar( // 🚫 Защита от слишком частого переключения (только cooldown, убираем строгие блокировки) val currentTime = System.currentTimeMillis() val timeSinceLastToggle = currentTime - lastToggleTime - + if (timeSinceLastToggle < toggleCooldownMs) { return } - + lastToggleTime = currentTime - + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - + coordinator.logState() - - // 🔥 ИСПОЛЬЗУЕМ coordinator.isEmojiVisible вместо showEmojiPicker для более точного состояния + + // 🔥 ИСПОЛЬЗУЕМ coordinator.isEmojiVisible вместо showEmojiPicker для более точного + // состояния if (coordinator.isEmojiVisible) { // ========== EMOJI → KEYBOARD ========== coordinator.requestShowKeyboard( - showKeyboard = { - editTextView?.let { editText -> - editText.requestFocus() - imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED) - } - }, - hideEmoji = { - onToggleEmojiPicker(false) - } + showKeyboard = { + editTextView?.let { editText -> + editText.requestFocus() + imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED) + } + }, + hideEmoji = { onToggleEmojiPicker(false) } ) } else { // ========== KEYBOARD → EMOJI ========== coordinator.requestShowEmoji( - hideKeyboard = { - imm.hideSoftInputFromWindow(view.windowToken, 0) - }, - showEmoji = { - onToggleEmojiPicker(true) - } + hideKeyboard = { imm.hideSoftInputFromWindow(view.windowToken, 0) }, + showEmoji = { onToggleEmojiPicker(true) } ) } - } // Функция отправки - НЕ закрывает клавиатуру (UX правило #6) @@ -2291,554 +2548,626 @@ private fun MessageInputBar( } Column( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { clip = false } - // imePadding уже применён к родительскому контейнеру - ) { + modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false } + // imePadding уже применён к родительскому контейнеру + ) { // Если пользователь заблокирован - показываем BlockedChatFooter (плоский как инпут) if (isBlocked) { // BLOCKED CHAT FOOTER - плоский стиль - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor) - ) { + Column(modifier = Modifier.fillMaxWidth().background(backgroundColor)) { // Border сверху Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background(if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.08f)) + modifier = + Modifier.fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f) + ) ) - + // BLOCKED CHAT FOOTER - плоский стиль // 🔥 Высота должна совпадать с MessageInputBar и Selection Action Bar Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .padding(bottom = 16.dp) - .navigationBarsPadding(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + .padding(bottom = 16.dp) + .navigationBarsPadding(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center ) { Icon( - Icons.Default.Block, - contentDescription = null, - tint = Color(0xFFFF6B6B), - modifier = Modifier.size(20.dp) + Icons.Default.Block, + contentDescription = null, + tint = Color(0xFFFF6B6B), + modifier = Modifier.size(20.dp) ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "You need to unblock user to send messages.", - fontSize = 14.sp, - color = secondaryTextColor, - textAlign = androidx.compose.ui.text.style.TextAlign.Center + text = "You need to unblock user to send messages.", + fontSize = 14.sp, + color = secondaryTextColor, + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) } } } else { - // 🔥 TELEGRAM STYLE: фон как у чата, верхний border - Column( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { clip = false } - ) { - // Верхний border (как в архиве) - Box( - modifier = Modifier - .fillMaxWidth() - .height(0.5.dp) - .background( - if (isDarkTheme) Color.White.copy(alpha = 0.1f) - else Color.Black.copy(alpha = 0.08f) - ) - ) - - // 🔥 Когда emoji Box виден ИЛИ клавиатура открыта - НЕ добавляем navigation bar padding - val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - color = backgroundColor // Тот же цвет что и фон чата - ) - .padding(bottom = if (isKeyboardVisible || coordinator.isEmojiBoxVisible) 0.dp else 16.dp) - .then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier) - ) { - // REPLY PANEL - плавная анимация появления/исчезновения - AnimatedVisibility( - visible = hasReply, - enter = fadeIn( - animationSpec = tween( - durationMillis = 200, - easing = FastOutSlowInEasing - ) - ) + expandVertically( - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - expandFrom = Alignment.Bottom - ), - exit = fadeOut( - animationSpec = tween( - durationMillis = 150, - easing = FastOutLinearInEasing - ) - ) + shrinkVertically( - animationSpec = tween( - durationMillis = 150, - easing = FastOutLinearInEasing - ), - shrinkTowards = Alignment.Bottom + // 🔥 TELEGRAM STYLE: фон как у чата, верхний border + Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) { + // Верхний border (как в архиве) + Box( + modifier = + Modifier.fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.1f) + else Color.Black.copy(alpha = 0.08f) + ) ) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - // 🔥 При клике на reply preview - скроллим к первому сообщению - if (displayReplyMessages.isNotEmpty()) { - onReplyClick(displayReplyMessages.first().messageId) + + // 🔥 Когда emoji Box виден ИЛИ клавиатура открыта - НЕ добавляем navigation bar + // padding + val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible + + Column( + modifier = + Modifier.fillMaxWidth() + .background( + color = backgroundColor // Тот же цвет что и фон + // чата + ) + .padding( + bottom = + if (isKeyboardVisible || + coordinator + .isEmojiBoxVisible + ) + 0.dp + else 16.dp + ) + .then( + if (shouldAddNavBarPadding) + Modifier.navigationBarsPadding() + else Modifier + ) + ) { + // REPLY PANEL - плавная анимация появления/исчезновения + AnimatedVisibility( + visible = hasReply, + enter = + fadeIn( + animationSpec = + tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ) + ) + + expandVertically( + animationSpec = + spring( + dampingRatio = + Spring.DampingRatioMediumBouncy, + stiffness = + Spring.StiffnessMedium + ), + expandFrom = Alignment.Bottom + ), + exit = + fadeOut( + animationSpec = + tween( + durationMillis = 150, + easing = FastOutLinearInEasing + ) + ) + + shrinkVertically( + animationSpec = + tween( + durationMillis = 150, + easing = FastOutLinearInEasing + ), + shrinkTowards = Alignment.Bottom + ) + ) { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable { + // 🔥 При клике на reply preview - скроллим к + // первому сообщению + if (displayReplyMessages.isNotEmpty()) { + onReplyClick( + displayReplyMessages.first() + .messageId + ) + } + } + .background( + backgroundColor + ) // Тот же цвет что и фон чата + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = + Modifier.width(3.dp) + .height(32.dp) + .background( + PrimaryBlue, + RoundedCornerShape(1.5.dp) + ) + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = + if (isForwardMode) + "Forward message${if (displayReplyMessages.size > 1) "s" else ""}" + else + "Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}", + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = PrimaryBlue, + maxLines = 1 + ) + Spacer(modifier = Modifier.height(2.dp)) + // Превью ответа + if (displayReplyMessages.isNotEmpty()) { + Text( + text = + if (displayReplyMessages.size == 1) { + val msg = displayReplyMessages.first() + val shortText = msg.text.take(40) + if (shortText.length < msg.text.length) + "$shortText..." + else shortText + } else "${displayReplyMessages.size} messages", + fontSize = 13.sp, + color = + if (isDarkTheme) Color.White.copy(alpha = 0.6f) + else Color.Black.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + // 🔥 Box с clickable вместо IconButton - убираем задержку ripple + Box( + modifier = + Modifier.size(32.dp) + .clickable( + interactionSource = + remember { + MutableInteractionSource() + }, + indication = null, // Убираем ripple + // индикацию для + // мгновенного клика + onClick = onCloseReply + ), + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Close, + contentDescription = "Cancel", + tint = + if (isDarkTheme) Color.White.copy(alpha = 0.5f) + else Color.Black.copy(alpha = 0.4f), + modifier = Modifier.size(18.dp) + ) } } - .background(backgroundColor) // Тот же цвет что и фон чата - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .width(3.dp) - .height(32.dp) - .background(PrimaryBlue, RoundedCornerShape(1.5.dp)) - ) - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = if (isForwardMode) "Forward message${if (displayReplyMessages.size > 1) "s" else ""}" - else "Reply to ${if (displayReplyMessages.size == 1 && !displayReplyMessages.first().isOutgoing) chatTitle else "You"}", - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - color = PrimaryBlue, - maxLines = 1 - ) - Spacer(modifier = Modifier.height(2.dp)) - // Превью ответа - if (displayReplyMessages.isNotEmpty()) { - Text( - text = if (displayReplyMessages.size == 1) { - val msg = displayReplyMessages.first() - val shortText = msg.text.take(40) - if (shortText.length < msg.text.length) "$shortText..." else shortText - } else "${displayReplyMessages.size} messages", - fontSize = 13.sp, - color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) - else Color.Black.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis + } + + // INPUT ROW - Paperclip → TextField → Emoji → Send/Mic (ПЛОСКИЙ ДИЗАЙН) + Row( + modifier = + Modifier.fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + // PAPERCLIP BUTTON (слева) + IconButton( + onClick = { /* TODO: Attach file/image */}, + modifier = Modifier.size(40.dp) + ) { + Icon( + Icons.Default.AttachFile, + contentDescription = "Attach", + tint = + if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) + else Color(0xFF8E8E93).copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) ) } - } - // 🔥 Box с clickable вместо IconButton - убираем задержку ripple - Box( - modifier = Modifier - .size(32.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, // Убираем ripple индикацию для мгновенного клика - onClick = onCloseReply - ), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Default.Close, - contentDescription = "Cancel", - tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) - else Color.Black.copy(alpha = 0.4f), - modifier = Modifier.size(18.dp) - ) - } - } - } - - // INPUT ROW - Paperclip → TextField → Emoji → Send/Mic (ПЛОСКИЙ ДИЗАЙН) - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom - ) { - // PAPERCLIP BUTTON (слева) - IconButton( - onClick = { /* TODO: Attach file/image */ }, - modifier = Modifier.size(40.dp) - ) { - Icon( - Icons.Default.AttachFile, - contentDescription = "Attach", - tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(4.dp)) - - // TEXT INPUT - ПЛОСКИЙ (тот же цвет что и фон чата) - Box( - modifier = Modifier - .weight(1f) - .heightIn(min = 40.dp, max = 150.dp) // 🔥 Ограничиваем максимум, но даём расти - .background( - color = backgroundColor // Тот же цвет что и фон чата - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - contentAlignment = Alignment.TopStart // 🔥 TopStart чтобы текст не обрезался - ) { - AppleEmojiTextField( - value = value, - onValueChange = { newValue -> onValueChange(newValue) }, - textColor = textColor, - textSize = 16f, - hint = "Type message...", - hintColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93), - modifier = Modifier.fillMaxWidth(), - requestFocus = hasReply, - onViewCreated = { view -> - // 🔥 Сохраняем ссылку на EditText для программного открытия клавиатуры - editTextView = view - }, - onFocusChanged = { hasFocus -> - - // Если TextField получил фокус И emoji открыт → закрываем emoji - if (hasFocus && showEmojiPicker) { - onToggleEmojiPicker(false) - } else if (hasFocus && !showEmojiPicker) { - } else if (!hasFocus) { + + Spacer(modifier = Modifier.width(4.dp)) + + // TEXT INPUT - ПЛОСКИЙ (тот же цвет что и фон чата) + Box( + modifier = + Modifier.weight(1f) + .heightIn( + min = 40.dp, + max = 150.dp + ) // 🔥 Ограничиваем максимум, но даём расти + .background( + color = backgroundColor // Тот же цвет что и + // фон чата + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = + Alignment.TopStart // 🔥 TopStart чтобы текст не обрезался + ) { + AppleEmojiTextField( + value = value, + onValueChange = { newValue -> onValueChange(newValue) }, + textColor = textColor, + textSize = 16f, + hint = "Type message...", + hintColor = + if (isDarkTheme) Color(0xFF8E8E93) + else Color(0xFF8E8E93), + modifier = Modifier.fillMaxWidth(), + requestFocus = hasReply, + onViewCreated = { view -> + // 🔥 Сохраняем ссылку на EditText для программного открытия + // клавиатуры + editTextView = view + }, + onFocusChanged = { hasFocus -> + + // Если TextField получил фокус И emoji открыт → закрываем + // emoji + if (hasFocus && showEmojiPicker) { + onToggleEmojiPicker(false) + } else if (hasFocus && !showEmojiPicker + ) {} else if (!hasFocus) {} + } + ) + } + + Spacer(modifier = Modifier.width(6.dp)) + + // EMOJI BUTTON (между input и send) + IconButton( + onClick = { toggleEmojiPicker() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + if (showEmojiPicker) Icons.Default.Keyboard + else Icons.Default.SentimentSatisfiedAlt, + contentDescription = "Emoji", + tint = + if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) + else Color(0xFF8E8E93).copy(alpha = 0.6f), + modifier = Modifier.size(24.dp) + ) + } + + Spacer(modifier = Modifier.width(2.dp)) + + // SEND BUTTON (всегда справа) - с анимацией + // 🔥 Кнопка видна если: есть текст ИЛИ есть reply ИЛИ идёт отправка + AnimatedVisibility( + visible = canSend || isSending, + enter = scaleIn(tween(150)) + fadeIn(tween(150)), + exit = scaleOut(tween(100)) + fadeOut(tween(100)) + ) { + IconButton( + onClick = { handleSend() }, + modifier = Modifier.size(40.dp) + ) { + Icon( + imageVector = TelegramSendIcon, + contentDescription = "Send", + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) } } - ) - } - - Spacer(modifier = Modifier.width(6.dp)) - - // EMOJI BUTTON (между input и send) - IconButton( - onClick = { toggleEmojiPicker() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - if (showEmojiPicker) Icons.Default.Keyboard - else Icons.Default.SentimentSatisfiedAlt, - contentDescription = "Emoji", - tint = if (isDarkTheme) Color(0xFF8E8E93).copy(alpha = 0.6f) else Color(0xFF8E8E93).copy(alpha = 0.6f), - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(2.dp)) - - // SEND BUTTON (всегда справа) - с анимацией - // 🔥 Кнопка видна если: есть текст ИЛИ есть reply ИЛИ идёт отправка - AnimatedVisibility( - visible = canSend || isSending, - enter = scaleIn(tween(150)) + fadeIn(tween(150)), - exit = scaleOut(tween(100)) + fadeOut(tween(100)) - ) { - IconButton( - onClick = { handleSend() }, - modifier = Modifier.size(40.dp) - ) { - Icon( - imageVector = TelegramSendIcon, - contentDescription = "Send", - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) } - } - } - } // Закрытие внутренней Column с padding - } // Закрытие внешней Column с border + } // Закрытие внутренней Column с padding + } // Закрытие внешней Column с border } // End of else (not blocked) - // 🔥 EMOJI PICKER с плавными Telegram-style анимациями if (!isBlocked) { AnimatedKeyboardTransition( - coordinator = coordinator, - showEmojiPicker = showEmojiPicker + coordinator = coordinator, + showEmojiPicker = showEmojiPicker ) { OptimizedEmojiPicker( - isVisible = true, // Видимость контролирует AnimatedKeyboardTransition - isDarkTheme = isDarkTheme, - onEmojiSelected = { emoji -> - onValueChange(value + emoji) - }, - onClose = { - // Используем coordinator для плавного перехода - toggleEmojiPicker() - }, - modifier = Modifier.fillMaxWidth() + isVisible = true, // Видимость контролирует AnimatedKeyboardTransition + isDarkTheme = isDarkTheme, + onEmojiSelected = { emoji -> onValueChange(value + emoji) }, + onClose = { + // Используем coordinator для плавного перехода + toggleEmojiPicker() + }, + modifier = Modifier.fillMaxWidth() ) } } // End of if (!isBlocked) for emoji picker } } -/** - * 💬 Typing Indicator с анимацией точек (как в Telegram) - */ +/** 💬 Typing Indicator с анимацией точек (как в Telegram) */ @Composable fun TypingIndicator(isDarkTheme: Boolean) { val infiniteTransition = rememberInfiniteTransition(label = "typing") val typingColor = Color(0xFF54A9EB) // Голубой цвет - + Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = "typing", - fontSize = 13.sp, - color = typingColor - ) - + Text(text = "typing", fontSize = 13.sp, color = typingColor) + // 3 анимированные точки repeat(3) { index -> - val offsetY by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -4f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 600, - delayMillis = index * 100, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" - ) - + val offsetY by + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = -4f, + animationSpec = + infiniteRepeatable( + animation = + tween( + durationMillis = 600, + delayMillis = index * 100, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ), + label = "dot$index" + ) + Text( - text = ".", - fontSize = 13.sp, - color = typingColor, - modifier = Modifier.offset(y = offsetY.dp) + text = ".", + fontSize = 13.sp, + color = typingColor, + modifier = Modifier.offset(y = offsetY.dp) ) } } } /** - * 🔥 СКЕЛЕТОН для загрузки сообщений - * Показывает анимированные плейсхолдеры пока загружаются сообщения + * 🔥 СКЕЛЕТОН для загрузки сообщений Показывает анимированные плейсхолдеры пока загружаются + * сообщения */ @Composable -fun MessageSkeletonList( - isDarkTheme: Boolean, - modifier: Modifier = Modifier -) { +fun MessageSkeletonList(isDarkTheme: Boolean, modifier: Modifier = Modifier) { // 🔥 Серый цвет для всех пузырьков (нейтральный скелетон) val skeletonColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE0E0E0) - + // Shimmer анимация val infiniteTransition = rememberInfiniteTransition(label = "shimmer") - val shimmerAlpha by infiniteTransition.animateFloat( - initialValue = 0.4f, - targetValue = 0.8f, - animationSpec = infiniteRepeatable( - animation = tween(800, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "shimmerAlpha" - ) - + val shimmerAlpha by + infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 0.8f, + animationSpec = + infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "shimmerAlpha" + ) + // 🔥 Box с выравниванием внизу - как настоящий чат Box(modifier = modifier) { Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = 8.dp) - .padding(bottom = 80.dp), // 🔥 Отступ от инпута - verticalArrangement = Arrangement.spacedBy(6.dp) + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 80.dp), // 🔥 Отступ от инпута + verticalArrangement = Arrangement.spacedBy(6.dp) ) { // Паттерн сообщений снизу вверх (как в реальном чате) - серые пузырьки - SkeletonBubble(isOutgoing = true, widthFraction = 0.45f, bubbleColor = skeletonColor, alpha = shimmerAlpha) - SkeletonBubble(isOutgoing = false, widthFraction = 0.55f, bubbleColor = skeletonColor, alpha = shimmerAlpha) - SkeletonBubble(isOutgoing = true, widthFraction = 0.35f, bubbleColor = skeletonColor, alpha = shimmerAlpha) - SkeletonBubble(isOutgoing = false, widthFraction = 0.50f, bubbleColor = skeletonColor, alpha = shimmerAlpha) - SkeletonBubble(isOutgoing = true, widthFraction = 0.60f, bubbleColor = skeletonColor, alpha = shimmerAlpha) - SkeletonBubble(isOutgoing = false, widthFraction = 0.40f, bubbleColor = skeletonColor, alpha = shimmerAlpha) + SkeletonBubble( + isOutgoing = true, + widthFraction = 0.45f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = false, + widthFraction = 0.55f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = true, + widthFraction = 0.35f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = false, + widthFraction = 0.50f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = true, + widthFraction = 0.60f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) + SkeletonBubble( + isOutgoing = false, + widthFraction = 0.40f, + bubbleColor = skeletonColor, + alpha = shimmerAlpha + ) } } } -/** - * Пузырёк-скелетон сообщения (толстый как настоящий с текстом) - */ +/** Пузырёк-скелетон сообщения (толстый как настоящий с текстом) */ @Composable private fun SkeletonBubble( - isOutgoing: Boolean, - widthFraction: Float, - bubbleColor: Color, - alpha: Float + isOutgoing: Boolean, + widthFraction: Float, + bubbleColor: Color, + alpha: Float ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start ) { Box( - modifier = Modifier - .fillMaxWidth(widthFraction) - .defaultMinSize(minHeight = 44.dp) // Минимум как пузырёк с текстом - .clip(RoundedCornerShape( - topStart = 18.dp, - topEnd = 18.dp, - bottomStart = if (isOutgoing) 18.dp else 6.dp, - bottomEnd = if (isOutgoing) 6.dp else 18.dp - )) - .background(bubbleColor.copy(alpha = alpha)) - .padding(horizontal = 14.dp, vertical = 10.dp) + modifier = + Modifier.fillMaxWidth(widthFraction) + .defaultMinSize(minHeight = 44.dp) // Минимум как пузырёк с текстом + .clip( + RoundedCornerShape( + topStart = 18.dp, + topEnd = 18.dp, + bottomStart = if (isOutgoing) 18.dp else 6.dp, + bottomEnd = if (isOutgoing) 6.dp else 18.dp + ) + ) + .background(bubbleColor.copy(alpha = alpha)) + .padding(horizontal = 14.dp, vertical = 10.dp) ) } } /** - * 🔥 Современное выпадающее меню в стиле Telegram - * Использует Popup вместо Material DropdownMenu - * С красивыми анимациями как в оригинальном Telegram + * 🔥 Современное выпадающее меню в стиле Telegram Использует Popup вместо Material DropdownMenu С + * красивыми анимациями как в оригинальном Telegram */ @Composable private fun ModernPopupMenu( - expanded: Boolean, - onDismissRequest: () -> Unit, - isDarkTheme: Boolean, - content: @Composable ColumnScope.() -> Unit + expanded: Boolean, + onDismissRequest: () -> Unit, + isDarkTheme: Boolean, + content: @Composable ColumnScope.() -> Unit ) { if (!expanded) return - + // Анимация появления в стиле Telegram - val scale by animateFloatAsState( - targetValue = if (expanded) 1f else 0.3f, - animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), - label = "scale" - ) - - val alpha by animateFloatAsState( - targetValue = if (expanded) 1f else 0f, - animationSpec = tween(150, easing = FastOutSlowInEasing), - label = "alpha" - ) - + val scale by + animateFloatAsState( + targetValue = if (expanded) 1f else 0.3f, + animationSpec = spring(dampingRatio = 0.75f, stiffness = 350f), + label = "scale" + ) + + val alpha by + animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = tween(150, easing = FastOutSlowInEasing), + label = "alpha" + ) + // Цвета меню с акцентным оттенком - val menuBackgroundColor = if (isDarkTheme) { - Color(0xFF212121) // Telegram dark menu - } else { - Color.White - } - + val menuBackgroundColor = + if (isDarkTheme) { + Color(0xFF212121) // Telegram dark menu + } else { + Color.White + } + val accentBorderColor = PrimaryBlue.copy(alpha = 0.3f) // Тонкая акцентная обводка - + Popup( - alignment = Alignment.TopEnd, - offset = IntOffset(-16, 60), // Отступ от кнопки меню - onDismissRequest = onDismissRequest, - properties = PopupProperties( - focusable = true, - dismissOnBackPress = true, - dismissOnClickOutside = true - ) + alignment = Alignment.TopEnd, + offset = IntOffset(-16, 60), // Отступ от кнопки меню + onDismissRequest = onDismissRequest, + properties = + PopupProperties( + focusable = true, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) ) { Column( - modifier = Modifier - .graphicsLayer { - scaleX = scale - scaleY = scale - this.alpha = alpha - transformOrigin = TransformOrigin(1f, 0f) // Анимация от правого верхнего угла - } - .width(220.dp) - .shadow( - elevation = 8.dp, - shape = RoundedCornerShape(12.dp), - spotColor = PrimaryBlue.copy(alpha = 0.2f), - ambientColor = PrimaryBlue.copy(alpha = 0.1f) - ) - .border( - width = 1.dp, - color = accentBorderColor, - shape = RoundedCornerShape(12.dp) - ) - .clip(RoundedCornerShape(12.dp)) - .background(menuBackgroundColor) - .padding(vertical = 8.dp) - ) { - content() - } + modifier = + Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + this.alpha = alpha + transformOrigin = + TransformOrigin( + 1f, + 0f + ) // Анимация от правого верхнего угла + } + .width(220.dp) + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(12.dp), + spotColor = PrimaryBlue.copy(alpha = 0.2f), + ambientColor = PrimaryBlue.copy(alpha = 0.1f) + ) + .border( + width = 1.dp, + color = accentBorderColor, + shape = RoundedCornerShape(12.dp) + ) + .clip(RoundedCornerShape(12.dp)) + .background(menuBackgroundColor) + .padding(vertical = 8.dp) + ) { content() } } } -/** - * 🔥 Элемент меню для Bottom Sheet - * Красивый дизайн с большими отступами для удобного нажатия - */ +/** 🔥 Элемент меню для Bottom Sheet Красивый дизайн с большими отступами для удобного нажатия */ @Composable private fun BottomSheetMenuItem( - icon: ImageVector, - text: String, - onClick: () -> Unit, - isDarkTheme: Boolean, - tintColor: Color = if (isDarkTheme) Color.White else Color.Black, - isDestructive: Boolean = false + icon: ImageVector, + text: String, + onClick: () -> Unit, + isDarkTheme: Boolean, + tintColor: Color = if (isDarkTheme) Color.White else Color.Black, + isDestructive: Boolean = false ) { val actualTintColor = if (isDestructive) Color(0xFFFF3B30) else tintColor - val textColor = if (isDestructive) { - Color(0xFFFF3B30) - } else if (isDarkTheme) { - Color.White - } else { - Color.Black - } - + val textColor = + if (isDestructive) { + Color(0xFFFF3B30) + } else if (isDarkTheme) { + Color.White + } else { + Color.Black + } + // Ripple эффект при нажатии val interactionSource = remember { MutableInteractionSource() } val isPressed = interactionSource.collectIsPressedAsState() - - val backgroundColor = if (isPressed.value) { - if (isDarkTheme) Color.White.copy(alpha = 0.08f) - else Color.Black.copy(alpha = 0.04f) - } else { - Color.Transparent - } - + + val backgroundColor = + if (isPressed.value) { + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.04f) + } else { + Color.Transparent + } + Row( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor) - .clickable( - interactionSource = interactionSource, - indication = null - ) { onClick() } - .padding(horizontal = 24.dp, vertical = 20.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .background(backgroundColor) + .clickable(interactionSource = interactionSource, indication = null) { + onClick() + } + .padding(horizontal = 24.dp, vertical = 20.dp), + verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = icon, - contentDescription = null, - tint = actualTintColor, - modifier = Modifier.size(28.dp) + imageVector = icon, + contentDescription = null, + tint = actualTintColor, + modifier = Modifier.size(28.dp) ) Spacer(modifier = Modifier.width(20.dp)) - Text( - text = text, - color = textColor, - fontSize = 18.sp, - fontWeight = FontWeight.Medium - ) + Text(text = text, color = textColor, fontSize = 18.sp, fontWeight = FontWeight.Medium) } } 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 6be3609..4c5a52c 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 @@ -534,38 +534,10 @@ fun ChatsListScreen( Spacer(modifier = Modifier.height(6.dp)) - // Connection status indicator - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier.clickable { showStatusDialog = true } - ) { - val statusColor = - when (protocolState) { - ProtocolState.AUTHENTICATED -> Color(0xFF4ADE80) - ProtocolState.CONNECTING, - ProtocolState.CONNECTED, - ProtocolState.HANDSHAKING -> Color(0xFFFBBF24) - else -> Color(0xFFF87171) - } - val statusText = - when (protocolState) { - ProtocolState.AUTHENTICATED -> "Online" - ProtocolState.CONNECTING, - ProtocolState.CONNECTED, - ProtocolState.HANDSHAKING -> "Connecting..." - else -> "Offline" - } - - Box( - modifier = - Modifier.size(8.dp) - .clip(CircleShape) - .background(statusColor) - ) - Spacer(modifier = Modifier.width(6.dp)) + // Username display + if (accountName.isNotEmpty()) { Text( - text = statusText, + text = "@$accountName", fontSize = 13.sp, color = Color.White.copy(alpha = 0.85f) ) @@ -864,7 +836,9 @@ fun ChatsListScreen( ProtocolState .AUTHENTICATED ) - textColor.copy(alpha = 0.6f) + textColor.copy( + alpha = 0.6f + ) else textColor.copy( alpha = 0.5f diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt index 4e92797..0b337a1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ForwardChatPickerBottomSheet.kt @@ -9,14 +9,13 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -24,13 +23,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat import java.util.* +import kotlinx.coroutines.launch /** * 📨 BottomSheet для выбора чата при Forward сообщений - * + * * Логика как в десктопной версии: * 1. Показывает список диалогов * 2. При выборе диалога - переходит в чат с сообщениями в Reply панели @@ -38,25 +36,23 @@ import java.util.* @OptIn(ExperimentalMaterial3Api::class) @Composable fun ForwardChatPickerBottomSheet( - dialogs: List, - isDarkTheme: Boolean, - currentUserPublicKey: String, - onDismiss: () -> Unit, - onChatSelected: (String) -> Unit + dialogs: List, + isDarkTheme: Boolean, + currentUserPublicKey: String, + onDismiss: () -> Unit, + onChatSelected: (String) -> Unit ) { - val sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = false - ) + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val scope = rememberCoroutineScope() - + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - + val forwardMessages by ForwardManager.forwardMessages.collectAsState() val messagesCount = forwardMessages.size - + // 🔥 Функция для красивого закрытия с анимацией fun dismissWithAnimation() { scope.launch { @@ -64,249 +60,232 @@ fun ForwardChatPickerBottomSheet( onDismiss() } } - + ModalBottomSheet( - onDismissRequest = { dismissWithAnimation() }, - sheetState = sheetState, - containerColor = backgroundColor, - dragHandle = { - // Кастомный handle - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(8.dp)) - Box( - modifier = Modifier - .width(36.dp) - .height(5.dp) - .clip(RoundedCornerShape(2.5.dp)) - .background( - if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6) - ) - ) - Spacer(modifier = Modifier.height(16.dp)) - } - }, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + onDismissRequest = { dismissWithAnimation() }, + sheetState = sheetState, + containerColor = backgroundColor, + dragHandle = { + // Кастомный handle + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = + Modifier.width(36.dp) + .height(5.dp) + .clip(RoundedCornerShape(2.5.dp)) + .background( + if (isDarkTheme) Color(0xFF4A4A4A) + else Color(0xFFD1D1D6) + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + } + }, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - ) { + Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { // Header Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { // Иконка и заголовок - Row( - verticalAlignment = Alignment.CenterVertically - ) { + Row(verticalAlignment = Alignment.CenterVertically) { // 🔥 Красивая иконка Forward Icon( - Icons.Filled.ArrowForward, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier - .size(24.dp) + Icons.Filled.ArrowForward, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) ) Spacer(modifier = Modifier.width(12.dp)) Column { Text( - text = "Forward to", - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - color = textColor + text = "Forward to", + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = textColor ) Text( - text = "$messagesCount message${if (messagesCount > 1) "s" else ""} selected", - fontSize = 14.sp, - color = secondaryTextColor + text = + "$messagesCount message${if (messagesCount > 1) "s" else ""} selected", + fontSize = 14.sp, + color = secondaryTextColor ) } } - + // Кнопка закрытия с анимацией IconButton(onClick = { dismissWithAnimation() }) { Icon( - Icons.Default.Close, - contentDescription = "Close", - tint = secondaryTextColor.copy(alpha = 0.6f) + Icons.Default.Close, + contentDescription = "Close", + tint = secondaryTextColor.copy(alpha = 0.6f) ) } } - + Spacer(modifier = Modifier.height(12.dp)) - - Divider( - color = dividerColor, - thickness = 0.5.dp - ) - + + Divider(color = dividerColor, thickness = 0.5.dp) + // Список диалогов if (dialogs.isEmpty()) { // Empty state Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxWidth().height(200.dp), + contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "No chats yet", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = secondaryTextColor + text = "No chats yet", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = secondaryTextColor ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Start a conversation first", - fontSize = 14.sp, - color = secondaryTextColor.copy(alpha = 0.7f) + text = "Start a conversation first", + fontSize = 14.sp, + color = secondaryTextColor.copy(alpha = 0.7f) ) } } } else { LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 300.dp, max = 400.dp) // 🔥 Минимальная высота для лучшего UX + modifier = + Modifier.fillMaxWidth() + .heightIn( + min = 300.dp, + max = 400.dp + ) // 🔥 Минимальная высота для лучшего UX ) { items(dialogs, key = { it.opponentKey }) { dialog -> ForwardDialogItem( - dialog = dialog, - isDarkTheme = isDarkTheme, - isSavedMessages = dialog.opponentKey == currentUserPublicKey, - onClick = { - onChatSelected(dialog.opponentKey) - } + dialog = dialog, + isDarkTheme = isDarkTheme, + isSavedMessages = dialog.opponentKey == currentUserPublicKey, + onClick = { onChatSelected(dialog.opponentKey) } ) - + // Сепаратор между диалогами if (dialog != dialogs.last()) { Divider( - modifier = Modifier.padding(start = 76.dp), - color = dividerColor, - thickness = 0.5.dp + modifier = Modifier.padding(start = 76.dp), + color = dividerColor, + thickness = 0.5.dp ) } } } } - + // Нижний padding Spacer(modifier = Modifier.height(16.dp)) } } } -/** - * Элемент диалога в списке выбора для Forward - */ +/** Элемент диалога в списке выбора для Forward */ @Composable private fun ForwardDialogItem( - dialog: DialogUiModel, - isDarkTheme: Boolean, - isSavedMessages: Boolean = false, - onClick: () -> Unit + dialog: DialogUiModel, + isDarkTheme: Boolean, + isSavedMessages: Boolean = false, + onClick: () -> Unit ) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - - val avatarColors = remember(dialog.opponentKey, isDarkTheme) { - getAvatarColor(dialog.opponentKey, isDarkTheme) - } - - val displayName = remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) { - when { - isSavedMessages -> "Saved Messages" - dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle - else -> dialog.opponentKey.take(8) - } - } - - val initials = remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) { - when { - isSavedMessages -> "📁" - dialog.opponentTitle.isNotEmpty() -> { - dialog.opponentTitle - .split(" ") - .take(2) - .mapNotNull { it.firstOrNull()?.uppercase() } - .joinToString("") + + val avatarColors = + remember(dialog.opponentKey, isDarkTheme) { + getAvatarColor(dialog.opponentKey, isDarkTheme) } - else -> dialog.opponentKey.take(2).uppercase() - } - } - + + val displayName = + remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) { + when { + isSavedMessages -> "Saved Messages" + dialog.opponentTitle.isNotEmpty() -> dialog.opponentTitle + else -> dialog.opponentKey.take(8) + } + } + + val initials = + remember(dialog.opponentTitle, dialog.opponentKey, isSavedMessages) { + when { + isSavedMessages -> "📁" + dialog.opponentTitle.isNotEmpty() -> { + dialog.opponentTitle + .split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + } + else -> dialog.opponentKey.take(2).uppercase() + } + } + Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { // Avatar Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background( - if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f) - else avatarColors.backgroundColor - ), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background( + if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f) + else avatarColors.backgroundColor + ), + contentAlignment = Alignment.Center ) { Text( - text = initials, - color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp + text = initials, + color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp ) } - + Spacer(modifier = Modifier.width(12.dp)) - + // Info - Column( - modifier = Modifier.weight(1f) - ) { + Column(modifier = Modifier.weight(1f)) { Text( - text = displayName, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = displayName, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - + Spacer(modifier = Modifier.height(2.dp)) - + Text( - text = if (isSavedMessages) "Your personal notes" else dialog.lastMessage.ifEmpty { "No messages" }, - fontSize = 14.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = + if (isSavedMessages) "Your personal notes" + else dialog.lastMessage.ifEmpty { "No messages" }, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } - + // Online indicator if (!isSavedMessages && dialog.isOnline == 1) { - Box( - modifier = Modifier - .size(10.dp) - .clip(CircleShape) - .background(Color(0xFF34C759)) - ) + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF34C759))) } } } 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 c0cba5f..6c7cbd3 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 @@ -1,5 +1,7 @@ package com.rosetta.messenger.ui.chats +import android.content.Context +import android.view.inputmethod.InputMethodManager import androidx.compose.animation.* import androidx.compose.animation.core.* import androidx.compose.foundation.* @@ -21,8 +23,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalView -import android.content.Context -import android.view.inputmethod.InputMethodManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -36,260 +36,251 @@ import com.rosetta.messenger.network.SearchUser // Primary Blue color private val PrimaryBlue = Color(0xFF54A9EB) -/** - * Отдельная страница поиска пользователей - * Хедер на всю ширину с полем ввода - */ +/** Отдельная страница поиска пользователей Хедер на всю ширину с полем ввода */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen( - privateKeyHash: String, - currentUserPublicKey: String, - isDarkTheme: Boolean, - protocolState: ProtocolState, - onBackClick: () -> Unit, - onUserSelect: (SearchUser) -> Unit + privateKeyHash: String, + currentUserPublicKey: String, + isDarkTheme: Boolean, + protocolState: ProtocolState, + onBackClick: () -> Unit, + onUserSelect: (SearchUser) -> Unit ) { // Context и View для мгновенного закрытия клавиатуры val context = LocalContext.current val view = LocalView.current val focusManager = LocalFocusManager.current - + // 🔥 Функция мгновенного закрытия клавиатуры val hideKeyboardInstantly: () -> Unit = { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) focusManager.clearFocus() } - + // Цвета ТОЧНО как в ChatsListScreen val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a) val secondaryTextColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d) val surfaceColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White - + // Search ViewModel val searchViewModel = remember { SearchUsersViewModel() } val searchQuery by searchViewModel.searchQuery.collectAsState() val searchResults by searchViewModel.searchResults.collectAsState() val isSearching by searchViewModel.isSearching.collectAsState() - + // Recent users (не текстовые запросы, а пользователи) val recentUsers by RecentSearchesManager.recentUsers.collectAsState() - + // Preload Lottie composition for search animation - val searchLottieComposition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.search)) - + val searchLottieComposition by + rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.search)) + // Устанавливаем аккаунт для RecentSearchesManager LaunchedEffect(currentUserPublicKey) { if (currentUserPublicKey.isNotEmpty()) { RecentSearchesManager.setAccount(currentUserPublicKey) } } - + // Устанавливаем privateKeyHash LaunchedEffect(privateKeyHash) { if (privateKeyHash.isNotEmpty()) { searchViewModel.setPrivateKeyHash(privateKeyHash) } } - + // Focus requester для автофокуса val focusRequester = remember { FocusRequester() } - + // Автофокус при открытии LaunchedEffect(Unit) { kotlinx.coroutines.delay(100) focusRequester.requestFocus() } - + Scaffold( - topBar = { - // Кастомный header с полем ввода на всю ширину - Surface( - modifier = Modifier.fillMaxWidth(), - color = backgroundColor - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .height(64.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Кнопка назад - с мгновенным закрытием клавиатуры - IconButton(onClick = { - hideKeyboardInstantly() - onBackClick() - }) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Back", - tint = textColor.copy(alpha = 0.6f) - ) - } - - // Поле ввода на всю оставшуюся ширину - Box( - modifier = Modifier - .weight(1f) - .padding(end = 8.dp) + topBar = { + // Кастомный header с полем ввода на всю ширину + Surface(modifier = Modifier.fillMaxWidth(), color = backgroundColor) { + Row( + modifier = + Modifier.fillMaxWidth() + .statusBarsPadding() + .height(64.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically ) { - TextField( - value = searchQuery, - onValueChange = { searchViewModel.onSearchQueryChange(it) }, - placeholder = { - Text( - "Search users...", - color = secondaryTextColor.copy(alpha = 0.7f), - fontSize = 16.sp - ) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedTextColor = textColor, - unfocusedTextColor = textColor, - cursorColor = PrimaryBlue, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - textStyle = androidx.compose.ui.text.TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ), - singleLine = true, - enabled = protocolState == ProtocolState.AUTHENTICATED, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - ) - - // Подчеркивание - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(2.dp) - .background( - PrimaryBlue.copy(alpha = 0.8f), - RoundedCornerShape(1.dp) - ) - ) - } - - // Кнопка очистки - AnimatedVisibility( - visible = searchQuery.isNotEmpty(), - enter = fadeIn(tween(150)) + scaleIn(tween(150)), - exit = fadeOut(tween(150)) + scaleOut(tween(150)) - ) { - IconButton(onClick = { searchViewModel.clearSearchQuery() }) { + // Кнопка назад - с мгновенным закрытием клавиатуры + IconButton( + onClick = { + hideKeyboardInstantly() + onBackClick() + } + ) { Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = secondaryTextColor.copy(alpha = 0.6f) + Icons.Default.ArrowBack, + contentDescription = "Back", + tint = textColor.copy(alpha = 0.6f) ) } + + // Поле ввода на всю оставшуюся ширину + Box(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + TextField( + value = searchQuery, + onValueChange = { searchViewModel.onSearchQueryChange(it) }, + placeholder = { + Text( + "Search users...", + color = secondaryTextColor.copy(alpha = 0.7f), + fontSize = 16.sp + ) + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + focusedTextColor = textColor, + unfocusedTextColor = textColor, + cursorColor = PrimaryBlue, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + textStyle = + androidx.compose.ui.text.TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ), + singleLine = true, + enabled = protocolState == ProtocolState.AUTHENTICATED, + modifier = + Modifier.fillMaxWidth().focusRequester(focusRequester) + ) + + // Подчеркивание + Box( + modifier = + Modifier.align(Alignment.BottomCenter) + .fillMaxWidth() + .height(2.dp) + .background( + PrimaryBlue.copy(alpha = 0.8f), + RoundedCornerShape(1.dp) + ) + ) + } + + // Кнопка очистки + AnimatedVisibility( + visible = searchQuery.isNotEmpty(), + enter = fadeIn(tween(150)) + scaleIn(tween(150)), + exit = fadeOut(tween(150)) + scaleOut(tween(150)) + ) { + IconButton(onClick = { searchViewModel.clearSearchQuery() }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = secondaryTextColor.copy(alpha = 0.6f) + ) + } + } } } - } - }, - containerColor = backgroundColor + }, + containerColor = backgroundColor ) { paddingValues -> // Контент - показываем recent users если поле пустое, иначе результаты - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) { // Recent Users с аватарками LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(vertical = 8.dp) + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) ) { item { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - "Recent", - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = secondaryTextColor + "Recent", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = secondaryTextColor ) TextButton(onClick = { RecentSearchesManager.clearAll() }) { - Text( - "Clear All", - fontSize = 13.sp, - color = PrimaryBlue - ) + Text("Clear All", fontSize = 13.sp, color = PrimaryBlue) } } } - + items(recentUsers, key = { it.publicKey }) { user -> RecentUserItem( - user = user, - isDarkTheme = isDarkTheme, - textColor = textColor, - secondaryTextColor = secondaryTextColor, - onClick = { - hideKeyboardInstantly() - RecentSearchesManager.addUser(user) - onUserSelect(user) - }, - onRemove = { - RecentSearchesManager.removeUser(user.publicKey) - } + user = user, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + onClick = { + hideKeyboardInstantly() + RecentSearchesManager.addUser(user) + onUserSelect(user) + }, + onRemove = { RecentSearchesManager.removeUser(user.publicKey) } ) } } } else { // Search Results // Проверяем, не ищет ли пользователь сам себя (Saved Messages) - val isSavedMessagesSearch = searchQuery.trim().let { query -> - query.equals(currentUserPublicKey, ignoreCase = true) || - query.equals(currentUserPublicKey.take(8), ignoreCase = true) || - query.equals(currentUserPublicKey.takeLast(8), ignoreCase = true) - } - - // Если ищем себя - показываем Saved Messages как первый результат - val resultsWithSavedMessages = if (isSavedMessagesSearch && searchResults.none { it.publicKey == currentUserPublicKey }) { - listOf( - SearchUser( - title = "Saved Messages", - username = "", - publicKey = currentUserPublicKey, - verified = 0, - online = 1 - ) - ) + searchResults.filter { it.publicKey != currentUserPublicKey } - } else { - searchResults - } - - SearchResultsList( - searchResults = resultsWithSavedMessages, - isSearching = isSearching, - currentUserPublicKey = currentUserPublicKey, - isDarkTheme = isDarkTheme, - preloadedComposition = searchLottieComposition, - onUserClick = { user -> - // Мгновенно закрываем клавиатуру - hideKeyboardInstantly() - // Сохраняем пользователя в историю (кроме Saved Messages) - if (user.publicKey != currentUserPublicKey) { - RecentSearchesManager.addUser(user) + val isSavedMessagesSearch = + searchQuery.trim().let { query -> + query.equals(currentUserPublicKey, ignoreCase = true) || + query.equals(currentUserPublicKey.take(8), ignoreCase = true) || + query.equals( + currentUserPublicKey.takeLast(8), + ignoreCase = true + ) + } + + // Если ищем себя - показываем Saved Messages как первый результат + val resultsWithSavedMessages = + if (isSavedMessagesSearch && + searchResults.none { it.publicKey == currentUserPublicKey } + ) { + listOf( + SearchUser( + title = "Saved Messages", + username = "", + publicKey = currentUserPublicKey, + verified = 0, + online = 1 + ) + ) + searchResults.filter { it.publicKey != currentUserPublicKey } + } else { + searchResults + } + + SearchResultsList( + searchResults = resultsWithSavedMessages, + isSearching = isSearching, + currentUserPublicKey = currentUserPublicKey, + isDarkTheme = isDarkTheme, + preloadedComposition = searchLottieComposition, + onUserClick = { user -> + // Мгновенно закрываем клавиатуру + hideKeyboardInstantly() + // Сохраняем пользователя в историю (кроме Saved Messages) + if (user.publicKey != currentUserPublicKey) { + RecentSearchesManager.addUser(user) + } + onUserSelect(user) } - onUserSelect(user) - } ) } } @@ -298,91 +289,85 @@ fun SearchScreen( @Composable private fun RecentUserItem( - user: SearchUser, - isDarkTheme: Boolean, - textColor: Color, - secondaryTextColor: Color, - onClick: () -> Unit, - onRemove: () -> Unit + user: SearchUser, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + onClick: () -> Unit, + onRemove: () -> Unit ) { - val displayName = user.title.ifEmpty { - user.username.ifEmpty { - user.publicKey.take(8) + "..." - } - } + val displayName = + user.title.ifEmpty { user.username.ifEmpty { user.publicKey.take(8) + "..." } } // Используем getInitials из ChatsListScreen val initials = getInitials(displayName) - + // Используем getAvatarColor из ChatsListScreen для правильных цветов val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) - + Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically ) { // Avatar Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = initials, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = avatarColors.textColor + text = initials, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = avatarColors.textColor ) } - + Spacer(modifier = Modifier.width(12.dp)) - + // Name and username Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( - text = displayName, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = displayName, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) if (user.verified != 0) { Spacer(modifier = Modifier.width(4.dp)) Icon( - Icons.Default.Verified, - contentDescription = "Verified", - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) + Icons.Default.Verified, + contentDescription = "Verified", + tint = PrimaryBlue, + modifier = Modifier.size(16.dp) ) } } if (user.username.isNotEmpty()) { Text( - text = "@${user.username}", - fontSize = 14.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = "@${user.username}", + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } - + // Remove button - IconButton( - onClick = onRemove, - modifier = Modifier.size(40.dp) - ) { + IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) { Icon( - Icons.Default.Close, - contentDescription = "Remove", - tint = secondaryTextColor.copy(alpha = 0.6f), - modifier = Modifier.size(20.dp) + Icons.Default.Close, + contentDescription = "Remove", + tint = secondaryTextColor.copy(alpha = 0.6f), + modifier = Modifier.size(20.dp) ) } }