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.

This commit is contained in:
2026-01-17 21:09:47 +05:00
parent c9136ed499
commit a3810af4a0
11 changed files with 3763 additions and 3226 deletions

View File

@@ -18,7 +18,7 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
// Optimize Lottie animations // Optimize Lottie animations
manifestPlaceholders["enableLottieOptimizations"] = "true" manifestPlaceholders["enableLottieOptimizations"] = "true"
} }
@@ -93,7 +93,7 @@ dependencies {
// Crypto libraries for key generation // Crypto libraries for key generation
implementation("org.bitcoinj:bitcoinj-core:0.16.2") implementation("org.bitcoinj:bitcoinj-core:0.16.2")
implementation("org.bouncycastle:bcprov-jdk15to18:1.77") implementation("org.bouncycastle:bcprov-jdk15to18:1.77")
// Google Tink for XChaCha20-Poly1305 // Google Tink for XChaCha20-Poly1305
implementation("com.google.crypto.tink:tink-android:1.10.0") implementation("com.google.crypto.tink:tink-android:1.10.0")
@@ -121,7 +121,7 @@ dependencies {
testImplementation("io.mockk:mockk:1.13.8") testImplementation("io.mockk:mockk:1.13.8")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
testImplementation("androidx.arch.core:core-testing:2.2.0") testImplementation("androidx.arch.core:core-testing:2.2.0")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) 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-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
} }
}

164
app/proguard-rules.pro vendored Normal file
View File

@@ -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 <fields>;
}
# ============================================================
# 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.* <methods>;
}
-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 <methods>;
}
# ============================================================
# 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 <fields>;
}
# ============================================================
# 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 <methods>;
}
# ============================================================
# 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.**

View File

@@ -13,9 +13,9 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
* Координатор переходов между системной клавиатурой и emoji панелью. * Координатор переходов между системной клавиатурой и emoji панелью. Реализует Telegram-style
* Реализует Telegram-style плавные анимации. * плавные анимации.
* *
* Ключевые принципы: * Ключевые принципы:
* - 250ms duration (как в Telegram AdjustPanLayoutHelper) * - 250ms duration (как в Telegram AdjustPanLayoutHelper)
* - Немедленное резервирование места * - Немедленное резервирование места
@@ -23,249 +23,252 @@ import androidx.compose.ui.unit.dp
* - Синхронизация всех переходов * - Синхронизация всех переходов
*/ */
class KeyboardTransitionCoordinator { class KeyboardTransitionCoordinator {
companion object { companion object {
const val TRANSITION_DURATION = 250L const val TRANSITION_DURATION = 250L
const val SHORT_DELAY = 50L const val SHORT_DELAY = 50L
private const val TAG = "KeyboardTransition" private const val TAG = "KeyboardTransition"
} }
// ============ Состояния переходов ============ // ============ Состояния переходов ============
enum class TransitionState { enum class TransitionState {
IDLE, // Ничего не происходит IDLE, // Ничего не происходит
KEYBOARD_TO_EMOJI, // Keyboard → Emoji KEYBOARD_TO_EMOJI, // Keyboard → Emoji
EMOJI_TO_KEYBOARD, // Emoji → Keyboard EMOJI_TO_KEYBOARD, // Emoji → Keyboard
KEYBOARD_OPENING, // Только keyboard открывается KEYBOARD_OPENING, // Только keyboard открывается
EMOJI_OPENING, // Только emoji открывается EMOJI_OPENING, // Только emoji открывается
KEYBOARD_CLOSING, // Только keyboard закрывается KEYBOARD_CLOSING, // Только keyboard закрывается
EMOJI_CLOSING // Только emoji закрывается EMOJI_CLOSING // Только emoji закрывается
} }
// ============ Основное состояние ============ // ============ Основное состояние ============
var currentState by mutableStateOf(TransitionState.IDLE) var currentState by mutableStateOf(TransitionState.IDLE)
private set private set
var transitionProgress by mutableFloatStateOf(0f) var transitionProgress by mutableFloatStateOf(0f)
private set private set
// ============ Высоты ============ // ============ Высоты ============
var keyboardHeight by mutableStateOf(0.dp) var keyboardHeight by mutableStateOf(0.dp)
var emojiHeight by mutableStateOf(0.dp) var emojiHeight by mutableStateOf(0.dp)
// 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji // 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji
private var maxKeyboardHeight by mutableStateOf(0.dp) private var maxKeyboardHeight by mutableStateOf(0.dp)
// ============ Флаги видимости ============ // ============ Флаги видимости ============
var isKeyboardVisible by mutableStateOf(false) var isKeyboardVisible by mutableStateOf(false)
var isEmojiVisible by mutableStateOf(false) var isEmojiVisible by mutableStateOf(false)
var isTransitioning by mutableStateOf(false) var isTransitioning by mutableStateOf(false)
private set private set
// 🔥 Показывается ли сейчас Box с эмодзи (включая анимацию fade-out) // 🔥 Показывается ли сейчас Box с эмодзи (включая анимацию fade-out)
// Используется для отключения imePadding пока Box виден // Используется для отключения imePadding пока Box виден
var isEmojiBoxVisible by mutableStateOf(false) var isEmojiBoxVisible by mutableStateOf(false)
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры) // 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
private var pendingShowEmojiCallback: (() -> Unit)? = null private var pendingShowEmojiCallback: (() -> Unit)? = null
// 📊 Для умного логирования (не каждый фрейм) // 📊 Для умного логирования (не каждый фрейм)
private var lastLogTime = 0L private var lastLogTime = 0L
private var lastLoggedHeight = -1f private var lastLoggedHeight = -1f
/** /**
* Переход от системной клавиатуры к emoji панели. * Переход от системной клавиатуры к emoji панели.
* *
* 🔥 КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Показываем emoji СРАЗУ! * 🔥 КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Показываем emoji СРАЗУ! Не ждем закрытия клавиатуры - emoji начинает
* Не ждем закрытия клавиатуры - emoji начинает выезжать синхронно. * выезжать синхронно.
*/ */
fun requestShowEmoji( fun requestShowEmoji(hideKeyboard: () -> Unit, showEmoji: () -> Unit) {
hideKeyboard: () -> Unit,
showEmoji: () -> Unit
) {
currentState = TransitionState.KEYBOARD_TO_EMOJI currentState = TransitionState.KEYBOARD_TO_EMOJI
isTransitioning = true isTransitioning = true
// 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры) // 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры)
if (maxKeyboardHeight > 0.dp) { if (maxKeyboardHeight > 0.dp) {
emojiHeight = maxKeyboardHeight emojiHeight = maxKeyboardHeight
} }
// 🔥 ПОКАЗЫВАЕМ EMOJI СРАЗУ! Не ждем закрытия клавиатуры // 🔥 ПОКАЗЫВАЕМ EMOJI СРАЗУ! Не ждем закрытия клавиатуры
showEmoji() showEmoji()
isEmojiVisible = true // 🔥 ВАЖНО: Устанавливаем флаг видимости emoji!
// Теперь скрываем клавиатуру (она будет закрываться синхронно с появлением emoji) // Теперь скрываем клавиатуру (она будет закрываться синхронно с появлением emoji)
Log.d(TAG, " ⌨️ Hiding keyboard...") Log.d(TAG, " ⌨️ Hiding keyboard...")
try { try {
hideKeyboard() hideKeyboard()
Log.d(TAG, " ✅ hideKeyboard() completed") Log.d(TAG, " ✅ hideKeyboard() completed")
} catch (e: Exception) { } catch (e: Exception) {}
}
isKeyboardVisible = false isKeyboardVisible = false
currentState = TransitionState.IDLE currentState = TransitionState.IDLE
isTransitioning = false isTransitioning = false
// Очищаем pending callback - больше не нужен // Очищаем pending callback - больше не нужен
pendingShowEmojiCallback = null pendingShowEmojiCallback = null
} }
// ============ Главный метод: Emoji → Keyboard ============ // ============ Главный метод: Emoji → Keyboard ============
/** /**
* Переход от emoji панели к системной клавиатуре. * Переход от emoji панели к системной клавиатуре. Telegram паттерн: показать клавиатуру и
* Telegram паттерн: показать клавиатуру и плавно скрыть emoji. * плавно скрыть emoji.
*/ */
fun requestShowKeyboard( fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
showKeyboard: () -> Unit,
hideEmoji: () -> Unit
) {
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт) // 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
if (pendingShowEmojiCallback != null) { if (pendingShowEmojiCallback != null) {
pendingShowEmojiCallback = null pendingShowEmojiCallback = null
} }
currentState = TransitionState.EMOJI_TO_KEYBOARD currentState = TransitionState.EMOJI_TO_KEYBOARD
isTransitioning = true isTransitioning = true
// Шаг 1: Показать системную клавиатуру // Шаг 1: Показать системную клавиатуру
try { try {
showKeyboard() showKeyboard()
} catch (e: Exception) { } catch (e: Exception) {}
}
// Шаг 2: Через небольшую задержку скрыть emoji // Шаг 2: Через небольшую задержку скрыть emoji
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper())
try { .postDelayed(
hideEmoji() {
isEmojiVisible = false try {
isKeyboardVisible = true hideEmoji()
isEmojiVisible = false
// Через время анимации завершаем переход isKeyboardVisible = true
Handler(Looper.getMainLooper()).postDelayed({
currentState = TransitionState.IDLE // Через время анимации завершаем переход
isTransitioning = false Handler(Looper.getMainLooper())
}, TRANSITION_DURATION) .postDelayed(
} catch (e: Exception) { {
currentState = TransitionState.IDLE currentState = TransitionState.IDLE
isTransitioning = false isTransitioning = false
} },
}, SHORT_DELAY) TRANSITION_DURATION
)
} catch (e: Exception) {
currentState = TransitionState.IDLE
isTransitioning = false
}
},
SHORT_DELAY
)
} }
// ============ Простые переходы ============ // ============ Простые переходы ============
/** /** Открыть только emoji панель (без клавиатуры). */
* Открыть только emoji панель (без клавиатуры).
*/
fun openEmojiOnly(showEmoji: () -> Unit) { fun openEmojiOnly(showEmoji: () -> Unit) {
currentState = TransitionState.EMOJI_OPENING currentState = TransitionState.EMOJI_OPENING
isTransitioning = true isTransitioning = true
// Установить высоту emoji равной сохраненной высоте клавиатуры // Установить высоту emoji равной сохраненной высоте клавиатуры
if (emojiHeight == 0.dp && keyboardHeight > 0.dp) { if (emojiHeight == 0.dp && keyboardHeight > 0.dp) {
emojiHeight = keyboardHeight emojiHeight = keyboardHeight
} }
showEmoji() showEmoji()
isEmojiVisible = true isEmojiVisible = true
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper())
currentState = TransitionState.IDLE .postDelayed(
isTransitioning = false {
}, TRANSITION_DURATION) currentState = TransitionState.IDLE
isTransitioning = false
},
TRANSITION_DURATION
)
} }
/** /** Закрыть emoji панель. */
* Закрыть emoji панель.
*/
fun closeEmoji(hideEmoji: () -> Unit) { fun closeEmoji(hideEmoji: () -> Unit) {
currentState = TransitionState.EMOJI_CLOSING currentState = TransitionState.EMOJI_CLOSING
isTransitioning = true isTransitioning = true
hideEmoji() hideEmoji()
isEmojiVisible = false isEmojiVisible = false
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper())
currentState = TransitionState.IDLE .postDelayed(
isTransitioning = false {
}, TRANSITION_DURATION) currentState = TransitionState.IDLE
isTransitioning = false
},
TRANSITION_DURATION
)
} }
/** /** Закрыть системную клавиатуру. */
* Закрыть системную клавиатуру.
*/
fun closeKeyboard(hideKeyboard: () -> Unit) { fun closeKeyboard(hideKeyboard: () -> Unit) {
currentState = TransitionState.KEYBOARD_CLOSING currentState = TransitionState.KEYBOARD_CLOSING
isTransitioning = true isTransitioning = true
hideKeyboard() hideKeyboard()
isKeyboardVisible = false isKeyboardVisible = false
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper())
currentState = TransitionState.IDLE .postDelayed(
isTransitioning = false {
}, TRANSITION_DURATION) currentState = TransitionState.IDLE
isTransitioning = false
},
TRANSITION_DURATION
)
} }
// ============ Вспомогательные методы ============ // ============ Вспомогательные методы ============
/** /** Обновить высоту клавиатуры из IME. */
* Обновить высоту клавиатуры из IME.
*/
fun updateKeyboardHeight(height: Dp) { fun updateKeyboardHeight(height: Dp) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val heightChanged = kotlin.math.abs(height.value - lastLoggedHeight) > 5f val heightChanged = kotlin.math.abs(height.value - lastLoggedHeight) > 5f
// Логируем раз в 50ms ИЛИ при значительном изменении высоты (>5dp) // Логируем раз в 50ms ИЛИ при значительном изменении высоты (>5dp)
if (heightChanged && (now - lastLogTime > 50 || lastLoggedHeight < 0)) { if (heightChanged && (now - lastLogTime > 50 || lastLoggedHeight < 0)) {
lastLogTime = now lastLogTime = now
lastLoggedHeight = height.value lastLoggedHeight = height.value
} }
if (height > 100.dp && height != keyboardHeight) { if (height > 100.dp && height != keyboardHeight) {
keyboardHeight = height keyboardHeight = height
// 🔥 Сохраняем максимальную высоту // 🔥 Сохраняем максимальную высоту
if (height > maxKeyboardHeight) { if (height > maxKeyboardHeight) {
maxKeyboardHeight = height maxKeyboardHeight = height
} }
// Если emoji высота не установлена, синхронизировать // Если emoji высота не установлена, синхронизировать
if (emojiHeight == 0.dp) { if (emojiHeight == 0.dp) {
emojiHeight = height emojiHeight = height
} }
} else if (height == 0.dp && keyboardHeight != 0.dp) { } else if (height == 0.dp && keyboardHeight != 0.dp) {
// 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты // 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты
// Восстанавливаем emojiHeight до максимальной высоты // Восстанавливаем emojiHeight до максимальной высоты
if (maxKeyboardHeight > 0.dp) { if (maxKeyboardHeight > 0.dp) {
emojiHeight = maxKeyboardHeight emojiHeight = maxKeyboardHeight
} }
// Обнуляем keyboardHeight // Обнуляем keyboardHeight
keyboardHeight = 0.dp keyboardHeight = 0.dp
} }
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji() // 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
} }
/** /** Обновить высоту emoji панели. */
* Обновить высоту emoji панели.
*/
fun updateEmojiHeight(height: Dp) { fun updateEmojiHeight(height: Dp) {
if (height > 0.dp && height != emojiHeight) { if (height > 0.dp && height != emojiHeight) {
emojiHeight = height emojiHeight = height
} }
} }
/** /**
* Синхронизировать высоты (emoji = keyboard). * Синхронизировать высоты (emoji = keyboard).
* *
* 🔥 ВАЖНО: Синхронизируем ТОЛЬКО когда клавиатура ОТКРЫВАЕТСЯ! * 🔥 ВАЖНО: Синхронизируем ТОЛЬКО когда клавиатура ОТКРЫВАЕТСЯ! При закрытии клавиатуры
* При закрытии клавиатуры emojiHeight должна оставаться фиксированной! * emojiHeight должна оставаться фиксированной!
*/ */
fun syncHeights() { fun syncHeights() {
// 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji // 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji
@@ -273,10 +276,10 @@ class KeyboardTransitionCoordinator {
emojiHeight = keyboardHeight emojiHeight = keyboardHeight
} }
} }
/** /**
* Инициализация высоты emoji панели (для pre-rendered подхода). * Инициализация высоты emoji панели (для pre-rendered подхода). Должна быть вызвана при старте
* Должна быть вызвана при старте для избежания 0dp высоты. * для избежания 0dp высоты.
*/ */
fun initializeEmojiHeight(height: Dp) { fun initializeEmojiHeight(height: Dp) {
if (emojiHeight == 0.dp && height > 0.dp) { if (emojiHeight == 0.dp && height > 0.dp) {
@@ -284,10 +287,10 @@ class KeyboardTransitionCoordinator {
maxKeyboardHeight = height maxKeyboardHeight = height
} }
} }
/** /**
* Получить текущую высоту для резервирования места. * Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
* Telegram паттерн: всегда резервировать максимум из двух. * максимум из двух.
*/ */
fun getReservedHeight(): Dp { fun getReservedHeight(): Dp {
return when { return when {
@@ -297,17 +300,13 @@ class KeyboardTransitionCoordinator {
else -> 0.dp else -> 0.dp
} }
} }
/** /** Проверка, можно ли начать новый переход. */
* Проверка, можно ли начать новый переход.
*/
fun canStartTransition(): Boolean { fun canStartTransition(): Boolean {
return !isTransitioning return !isTransitioning
} }
/** /** Сброс состояния (для отладки). */
* Сброс состояния (для отладки).
*/
fun reset() { fun reset() {
currentState = TransitionState.IDLE currentState = TransitionState.IDLE
isTransitioning = false isTransitioning = false
@@ -315,17 +314,12 @@ class KeyboardTransitionCoordinator {
isEmojiVisible = false isEmojiVisible = false
transitionProgress = 0f transitionProgress = 0f
} }
/** /** Логирование текущего состояния. */
* Логирование текущего состояния. fun logState() {}
*/
fun logState() {
}
} }
/** /** Composable для создания и запоминания coordinator'а. */
* Composable для создания и запоминания coordinator'а.
*/
@Composable @Composable
fun rememberKeyboardTransitionCoordinator(): KeyboardTransitionCoordinator { fun rememberKeyboardTransitionCoordinator(): KeyboardTransitionCoordinator {
return remember { KeyboardTransitionCoordinator() } return remember { KeyboardTransitionCoordinator() }

View File

@@ -350,7 +350,7 @@ fun MainScreen(
onToggleTheme: () -> Unit = {}, onToggleTheme: () -> Unit = {},
onLogout: () -> Unit = {} onLogout: () -> Unit = {}
) { ) {
val accountName = account?.publicKey ?: "04c266b98ae5" val accountName = account?.name ?: "Account"
val accountPhone = account?.publicKey?.take(16)?.let { val accountPhone = account?.publicKey?.take(16)?.let {
"+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}" "+${it.take(1)} ${it.substring(1, 4)} ${it.substring(4, 7)}${it.substring(7)}"
} ?: "+7 775 9932587" } ?: "+7 775 9932587"

View File

@@ -5,7 +5,6 @@ import androidx.compose.animation.core.*
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
@@ -35,21 +34,38 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SetPasswordScreen( fun SetPasswordScreen(
seedPhrase: List<String>, seedPhrase: List<String>,
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onAccountCreated: (DecryptedAccount) -> Unit onAccountCreated: (DecryptedAccount) -> Unit
) { ) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) val themeAnimSpec =
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) val backgroundColor by
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec) animateColorAsState(
val cardColor by animateColorAsState(if (isDarkTheme) AuthSurface else AuthSurfaceLight, animationSpec = themeAnimSpec) 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 context = LocalContext.current
val accountManager = remember { AccountManager(context) } val accountManager = remember { AccountManager(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") } var confirmPassword by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
@@ -57,492 +73,540 @@ fun SetPasswordScreen(
var isCreating by remember { mutableStateOf(false) } var isCreating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
// Track keyboard visibility // Track keyboard visibility
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
var isKeyboardVisible by remember { mutableStateOf(false) } var isKeyboardVisible by remember { mutableStateOf(false) }
DisposableEffect(view) { DisposableEffect(view) {
val listener = android.view.ViewTreeObserver.OnGlobalLayoutListener { val listener =
val rect = android.graphics.Rect() android.view.ViewTreeObserver.OnGlobalLayoutListener {
view.getWindowVisibleDisplayFrame(rect) val rect = android.graphics.Rect()
val screenHeight = view.rootView.height view.getWindowVisibleDisplayFrame(rect)
val keypadHeight = screenHeight - rect.bottom val screenHeight = view.rootView.height
isKeyboardVisible = keypadHeight > screenHeight * 0.15 val keypadHeight = screenHeight - rect.bottom
} isKeyboardVisible = keypadHeight > screenHeight * 0.15
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener) view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose { onDispose { view.viewTreeObserver.removeOnGlobalLayoutListener(listener) }
view.viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) { visible = true }
visible = true
}
val passwordsMatch = password == confirmPassword && password.isNotEmpty() val passwordsMatch = password == confirmPassword && password.isNotEmpty()
val isPasswordWeak = password.isNotEmpty() && password.length < 6 val isPasswordWeak = password.isNotEmpty() && password.length < 6
val canContinue = passwordsMatch && !isCreating val canContinue = passwordsMatch && !isCreating
Box( Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
modifier = Modifier Column(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top Bar // Top Bar
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
.fillMaxWidth() verticalAlignment = Alignment.CenterVertically
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
IconButton(onClick = onBack, enabled = !isCreating) { IconButton(onClick = onBack, enabled = !isCreating) {
Icon( Icon(
imageVector = Icons.Default.ArrowBack, imageVector = Icons.Default.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
tint = textColor.copy(alpha = 0.6f) tint = textColor.copy(alpha = 0.6f)
) )
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = "Set Password", text = "Set Password",
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp)) Spacer(modifier = Modifier.width(48.dp))
} }
Column( Column(
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize()
.imePadding() .imePadding()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp)) Spacer(modifier = Modifier.height(if (isKeyboardVisible) 8.dp else 16.dp))
// Lock Icon - smaller when keyboard is visible // Lock Icon - smaller when keyboard is visible
val iconSize by animateDpAsState( val iconSize by
targetValue = if (isKeyboardVisible) 48.dp else 80.dp, animateDpAsState(
animationSpec = tween(300, easing = FastOutSlowInEasing) 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, val iconInnerSize by
animationSpec = tween(300, easing = FastOutSlowInEasing) animateDpAsState(
) targetValue = if (isKeyboardVisible) 24.dp else 40.dp,
animationSpec = tween(300, easing = FastOutSlowInEasing)
)
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500)) + scaleIn( enter =
initialScale = 0.5f, fadeIn(tween(500)) +
animationSpec = tween(500, easing = FastOutSlowInEasing) scaleIn(
) initialScale = 0.5f,
animationSpec =
tween(500, easing = FastOutSlowInEasing)
)
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(iconSize) Modifier.size(iconSize)
.clip(RoundedCornerShape(if (isKeyboardVisible) 12.dp else 20.dp)) .clip(
.background(PrimaryBlue.copy(alpha = 0.1f)), RoundedCornerShape(
contentAlignment = Alignment.Center if (isKeyboardVisible) 12.dp else 20.dp
)
)
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Default.Lock, Icons.Default.Lock,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier.size(iconInnerSize) modifier = Modifier.size(iconInnerSize)
) )
} }
} }
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp)) Spacer(modifier = Modifier.height(if (isKeyboardVisible) 12.dp else 24.dp))
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 100)) + slideInVertically( enter =
initialOffsetY = { -20 }, fadeIn(tween(500, delayMillis = 100)) +
animationSpec = tween(500, delayMillis = 100) slideInVertically(
) initialOffsetY = { -20 },
animationSpec = tween(500, delayMillis = 100)
)
) { ) {
Text( Text(
text = "Protect Your Account", text = "Protect Your Account",
fontSize = if (isKeyboardVisible) 20.sp else 24.sp, fontSize = if (isKeyboardVisible) 20.sp else 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor color = textColor
) )
} }
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp)) Spacer(modifier = Modifier.height(if (isKeyboardVisible) 6.dp else 8.dp))
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 200)) enter = fadeIn(tween(500, delayMillis = 200))
) { ) {
Text( Text(
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.", text =
fontSize = if (isKeyboardVisible) 12.sp else 14.sp, "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
color = secondaryTextColor, fontSize = if (isKeyboardVisible) 12.sp else 14.sp,
textAlign = TextAlign.Center, color = secondaryTextColor,
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp textAlign = TextAlign.Center,
lineHeight = if (isKeyboardVisible) 16.sp else 20.sp
) )
} }
Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp)) Spacer(modifier = Modifier.height(if (isKeyboardVisible) 16.dp else 32.dp))
// Password Field // Password Field
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 300)) + slideInVertically( enter =
initialOffsetY = { 50 }, fadeIn(tween(500, delayMillis = 300)) +
animationSpec = tween(500, delayMillis = 300) slideInVertically(
) initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 300)
)
) { ) {
OutlinedTextField( OutlinedTextField(
value = password, value = password,
onValueChange = { onValueChange = {
password = it password = it
error = null error = null
}, },
label = { Text("Password") }, label = { Text("Password") },
placeholder = { Text("Enter password") }, placeholder = { Text("Enter password") },
singleLine = true, singleLine = true,
visualTransformation = if (passwordVisible) visualTransformation =
VisualTransformation.None else PasswordVisualTransformation(), if (passwordVisible) VisualTransformation.None
trailingIcon = { else PasswordVisualTransformation(),
IconButton(onClick = { passwordVisible = !passwordVisible }) { trailingIcon = {
Icon( IconButton(onClick = { passwordVisible = !passwordVisible }) {
imageVector = if (passwordVisible) Icon(
Icons.Default.VisibilityOff else Icons.Default.Visibility, imageVector =
contentDescription = if (passwordVisible) "Hide" else "Show" 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, colors =
cursorColor = PrimaryBlue, OutlinedTextFieldDefaults.colors(
focusedTextColor = textColor, focusedBorderColor = PrimaryBlue,
unfocusedTextColor = textColor unfocusedBorderColor =
), if (isDarkTheme) Color(0xFF4A4A4A)
modifier = Modifier.fillMaxWidth(), else Color(0xFFD0D0D0),
shape = RoundedCornerShape(12.dp), focusedLabelColor = PrimaryBlue,
keyboardOptions = KeyboardOptions( cursorColor = PrimaryBlue,
keyboardType = KeyboardType.Password, focusedTextColor = textColor,
imeAction = ImeAction.Next unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions =
KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
)
) )
)
} }
// Password strength indicator // Password strength indicator
if (password.isNotEmpty()) { if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = 350)) + slideInHorizontally( enter =
initialOffsetX = { -30 }, fadeIn(tween(400, delayMillis = 350)) +
animationSpec = tween(400, delayMillis = 350) slideInHorizontally(
) initialOffsetX = { -30 },
animationSpec = tween(400, delayMillis = 350)
)
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val strength = when { val strength =
password.length < 6 -> "Weak" when {
password.length < 10 -> "Medium" password.length < 6 -> "Weak"
else -> "Strong" password.length < 10 -> "Medium"
} else -> "Strong"
val strengthColor = when { }
password.length < 6 -> Color(0xFFE53935) val strengthColor =
password.length < 10 -> Color(0xFFFFA726) when {
else -> Color(0xFF4CAF50) password.length < 6 -> Color(0xFFE53935)
} password.length < 10 -> Color(0xFFFFA726)
else -> Color(0xFF4CAF50)
}
Icon( Icon(
imageVector = Icons.Default.Shield, imageVector = Icons.Default.Shield,
contentDescription = null, contentDescription = null,
tint = strengthColor, tint = strengthColor,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "Password strength: $strength", text = "Password strength: $strength",
fontSize = 12.sp, fontSize = 12.sp,
color = strengthColor color = strengthColor
) )
} }
// Warning for weak passwords // Warning for weak passwords
if (isPasswordWeak) { if (isPasswordWeak) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color(0xFFE53935).copy(alpha = 0.1f)) .background(
.padding(8.dp), Color(0xFFE53935).copy(alpha = 0.1f)
verticalAlignment = Alignment.Top )
.padding(8.dp),
verticalAlignment = Alignment.Top
) { ) {
Icon( Icon(
imageVector = Icons.Default.Warning, imageVector = Icons.Default.Warning,
contentDescription = null, contentDescription = null,
tint = Color(0xFFE53935), tint = Color(0xFFE53935),
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Your password is too weak. Consider using at least 6 characters for better security.", text =
fontSize = 11.sp, "Your password is too weak. Consider using at least 6 characters for better security.",
color = Color(0xFFE53935), fontSize = 11.sp,
lineHeight = 14.sp color = Color(0xFFE53935),
lineHeight = 14.sp
) )
} }
} }
} }
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Confirm Password Field // Confirm Password Field
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 400)) + slideInVertically( enter =
initialOffsetY = { 50 }, fadeIn(tween(500, delayMillis = 400)) +
animationSpec = tween(500, delayMillis = 400) slideInVertically(
) initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 400)
)
) { ) {
OutlinedTextField( OutlinedTextField(
value = confirmPassword, value = confirmPassword,
onValueChange = { onValueChange = {
confirmPassword = it confirmPassword = it
error = null error = null
}, },
label = { Text("Confirm Password") }, label = { Text("Confirm Password") },
placeholder = { Text("Re-enter password") }, placeholder = { Text("Re-enter password") },
singleLine = true, singleLine = true,
visualTransformation = if (confirmPasswordVisible) visualTransformation =
VisualTransformation.None else PasswordVisualTransformation(), if (confirmPasswordVisible) VisualTransformation.None
trailingIcon = { else PasswordVisualTransformation(),
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) { trailingIcon = {
Icon( IconButton(
imageVector = if (confirmPasswordVisible) onClick = {
Icons.Default.VisibilityOff else Icons.Default.Visibility, confirmPasswordVisible = !confirmPasswordVisible
contentDescription = if (confirmPasswordVisible) "Hide" else "Show" }
) ) {
} Icon(
}, imageVector =
isError = confirmPassword.isNotEmpty() && !passwordsMatch, if (confirmPasswordVisible)
colors = OutlinedTextFieldDefaults.colors( Icons.Default.VisibilityOff
focusedBorderColor = PrimaryBlue, else Icons.Default.Visibility,
unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0), contentDescription =
focusedLabelColor = PrimaryBlue, if (confirmPasswordVisible) "Hide" else "Show"
cursorColor = PrimaryBlue, )
focusedTextColor = textColor, }
unfocusedTextColor = textColor },
), isError = confirmPassword.isNotEmpty() && !passwordsMatch,
modifier = Modifier.fillMaxWidth(), colors =
shape = RoundedCornerShape(12.dp), OutlinedTextFieldDefaults.colors(
keyboardOptions = KeyboardOptions( focusedBorderColor = PrimaryBlue,
keyboardType = KeyboardType.Password, unfocusedBorderColor =
imeAction = ImeAction.Done 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 // Match indicator
if (confirmPassword.isNotEmpty()) { if (confirmPassword.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(400, delayMillis = 450)) + slideInHorizontally( enter =
initialOffsetX = { -30 }, fadeIn(tween(400, delayMillis = 450)) +
animationSpec = tween(400, delayMillis = 450) slideInHorizontally(
) initialOffsetX = { -30 },
animationSpec = tween(400, delayMillis = 450)
)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
val matchIcon = if (passwordsMatch) Icons.Default.Check else Icons.Default.Close val matchIcon =
val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935) if (passwordsMatch) Icons.Default.Check else Icons.Default.Close
val matchText = if (passwordsMatch) "Passwords match" else "Passwords don't match" val matchColor =
if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
val matchText =
if (passwordsMatch) "Passwords match"
else "Passwords don't match"
Icon( Icon(
imageVector = matchIcon, imageVector = matchIcon,
contentDescription = null, contentDescription = null,
tint = matchColor, tint = matchColor,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(text = matchText, fontSize = 12.sp, color = matchColor)
text = matchText, }
fontSize = 12.sp,
color = matchColor
)
}
} }
} }
// Error message // Error message
error?.let { errorMsg -> error?.let { errorMsg ->
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f) enter = fadeIn(tween(300)) + scaleIn(initialScale = 0.9f)
) { ) {
Text( Text(
text = errorMsg, text = errorMsg,
fontSize = 14.sp, fontSize = 14.sp,
color = Color(0xFFE53935), color = Color(0xFFE53935),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Info - hide when keyboard is visible // Info - hide when keyboard is visible
AnimatedVisibility( AnimatedVisibility(
visible = visible && !isKeyboardVisible, visible = visible && !isKeyboardVisible,
enter = fadeIn(tween(400)) + slideInVertically( enter =
initialOffsetY = { 30 }, fadeIn(tween(400)) +
animationSpec = tween(400) slideInVertically(
) + scaleIn( initialOffsetY = { 30 },
initialScale = 0.9f, animationSpec = tween(400)
animationSpec = tween(400) ) +
), scaleIn(initialScale = 0.9f, animationSpec = tween(400)),
exit = fadeOut(tween(300)) + slideOutVertically( exit =
targetOffsetY = { 30 }, fadeOut(tween(300)) +
animationSpec = tween(300) slideOutVertically(
) + scaleOut( targetOffsetY = { 30 },
targetScale = 0.9f, animationSpec = tween(300)
animationSpec = tween(300) ) +
) scaleOut(targetScale = 0.9f, animationSpec = tween(300))
) { ) {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(cardColor) .background(cardColor)
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
Icon( Icon(
imageVector = Icons.Default.Info, imageVector = Icons.Default.Info,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = "Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.", text =
fontSize = 13.sp, "Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.",
color = secondaryTextColor, fontSize = 13.sp,
lineHeight = 18.sp color = secondaryTextColor,
lineHeight = 18.sp
) )
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Create Account Button // Create Account Button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(500, delayMillis = 600)) + slideInVertically( enter =
initialOffsetY = { 50 }, fadeIn(tween(500, delayMillis = 600)) +
animationSpec = tween(500, delayMillis = 600) slideInVertically(
) initialOffsetY = { 50 },
animationSpec = tween(500, delayMillis = 600)
)
) { ) {
Button( Button(
onClick = { onClick = {
if (!passwordsMatch) { if (!passwordsMatch) {
error = "Passwords don't match" error = "Passwords don't match"
return@Button 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)) Spacer(modifier = Modifier.height(32.dp))
} }
} }

View File

@@ -3,7 +3,6 @@ package com.rosetta.messenger.ui.auth
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -14,7 +13,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
@@ -24,7 +22,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.* import com.airbnb.lottie.compose.*
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
// Auth colors // Auth colors
val AuthBackground = Color(0xFF1B1B1B) val AuthBackground = Color(0xFF1B1B1B)
@@ -34,229 +31,232 @@ val AuthSurfaceLight = Color(0xFFF5F5F5)
@Composable @Composable
fun WelcomeScreen( fun WelcomeScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
hasExistingAccount: Boolean = false, hasExistingAccount: Boolean = false,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onCreateSeed: () -> Unit, onCreateSeed: () -> Unit,
onImportSeed: () -> Unit onImportSeed: () -> Unit
) { ) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f)) val themeAnimSpec =
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec) tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec) val backgroundColor by
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec) 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 // Sync navigation bar color with background
val view = LocalView.current val view = LocalView.current
SideEffect { SideEffect {
val window = (view.context as? android.app.Activity)?.window val window = (view.context as? android.app.Activity)?.window
window?.navigationBarColor = backgroundColor.toArgb() window?.navigationBarColor = backgroundColor.toArgb()
} }
// Animation for Lottie // Animation for Lottie
val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json")) val lockComposition by
val lockProgress by animateLottieCompositionAsState( rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json"))
composition = lockComposition, val lockProgress by
iterations = 1, // Play once animateLottieCompositionAsState(
speed = 1f composition = lockComposition,
) iterations = 1, // Play once
speed = 1f
)
// Entry animation // Entry animation
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true } LaunchedEffect(Unit) { visible = true }
Box( Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) {
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
// Back button when coming from UnlockScreen // Back button when coming from UnlockScreen
if (hasExistingAccount) { if (hasExistingAccount) {
IconButton( IconButton(onClick = onBack, modifier = Modifier.statusBarsPadding().padding(4.dp)) {
onClick = onBack,
modifier = Modifier
.statusBarsPadding()
.padding(4.dp)
) {
Icon( Icon(
Icons.Default.ArrowBack, Icons.Default.ArrowBack,
contentDescription = "Back", contentDescription = "Back",
tint = textColor.copy(alpha = 0.6f) tint = textColor.copy(alpha = 0.6f)
) )
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp).statusBarsPadding(),
.fillMaxSize() horizontalAlignment = Alignment.CenterHorizontally
.padding(horizontal = 24.dp)
.statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.weight(0.15f)) Spacer(modifier = Modifier.weight(0.15f))
// Animated Lock Icon // Animated Lock Icon
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing)) enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing))
) { ) {
Box( Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) {
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
lockComposition?.let { comp -> lockComposition?.let { comp ->
LottieAnimation( LottieAnimation(
composition = comp, composition = comp,
progress = { lockProgress }, progress = { lockProgress },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
} }
} }
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Title // Title
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600, delayMillis = 200)) + slideInVertically( enter =
initialOffsetY = { 50 }, fadeIn(tween(600, delayMillis = 200)) +
animationSpec = tween(600, delayMillis = 200) slideInVertically(
) initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 200)
)
) { ) {
Text( Text(
text = "Your Keys,\nYour Messages", text = "Your Keys,\nYour Messages",
fontSize = 32.sp, fontSize = 32.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor, color = textColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = 40.sp lineHeight = 40.sp
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Subtitle // Subtitle
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600, delayMillis = 300)) + slideInVertically( enter =
initialOffsetY = { 50 }, fadeIn(tween(600, delayMillis = 300)) +
animationSpec = tween(600, delayMillis = 300) slideInVertically(
) initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 300)
)
) { ) {
Text( Text(
text = "Secure messaging with\ncryptographic keys", text = "Secure messaging with\ncryptographic keys",
fontSize = 16.sp, fontSize = 16.sp,
color = secondaryTextColor, color = secondaryTextColor,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = 24.sp, lineHeight = 24.sp,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
) )
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Features list with icons - placed above buttons // Features list with icons - placed above buttons
AnimatedVisibility( AnimatedVisibility(visible = visible, enter = fadeIn(tween(600, delayMillis = 400))) {
visible = visible,
enter = fadeIn(tween(600, delayMillis = 400))
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
CompactFeatureItem( CompactFeatureItem(
icon = Icons.Default.Security, icon = Icons.Default.Security,
text = "Encrypted", text = "Encrypted",
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
textColor = textColor textColor = textColor
) )
CompactFeatureItem( CompactFeatureItem(
icon = Icons.Default.NoAccounts, icon = Icons.Default.NoAccounts,
text = "No Phone", text = "No Phone",
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
textColor = textColor textColor = textColor
) )
CompactFeatureItem( CompactFeatureItem(
icon = Icons.Default.Key, icon = Icons.Default.Key,
text = "Your Keys", text = "Your Keys",
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
textColor = textColor textColor = textColor
) )
} }
} }
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Create Seed Button // Create Seed Button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically( enter =
initialOffsetY = { 100 }, fadeIn(tween(600, delayMillis = 500)) +
animationSpec = tween(600, delayMillis = 500) slideInVertically(
) initialOffsetY = { 100 },
animationSpec = tween(600, delayMillis = 500)
)
) { ) {
Button( Button(
onClick = onCreateSeed, onClick = onCreateSeed,
modifier = Modifier modifier = Modifier.fillMaxWidth().height(56.dp),
.fillMaxWidth() colors =
.height(56.dp), ButtonDefaults.buttonColors(
colors = ButtonDefaults.buttonColors( containerColor = PrimaryBlue,
containerColor = PrimaryBlue, contentColor = Color.White
contentColor = Color.White ),
), shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(16.dp), elevation =
elevation = ButtonDefaults.buttonElevation( ButtonDefaults.buttonElevation(
defaultElevation = 0.dp, defaultElevation = 0.dp,
pressedElevation = 0.dp pressedElevation = 0.dp
) )
) { ) {
Icon( Icon(
imageVector = Icons.Default.Key, imageVector = Icons.Default.Key,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Text( Text(
text = "Generate New Seed Phrase", text = "Generate New Seed Phrase",
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Import Seed Button // Import Seed Button
AnimatedVisibility( AnimatedVisibility(
visible = visible, visible = visible,
enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically( enter =
initialOffsetY = { 100 }, fadeIn(tween(600, delayMillis = 600)) +
animationSpec = tween(600, delayMillis = 600) slideInVertically(
) initialOffsetY = { 100 },
animationSpec = tween(600, delayMillis = 600)
)
) { ) {
TextButton( TextButton(
onClick = onImportSeed, onClick = onImportSeed,
modifier = Modifier modifier = Modifier.fillMaxWidth().height(56.dp),
.fillMaxWidth() shape = RoundedCornerShape(16.dp)
.height(56.dp),
shape = RoundedCornerShape(16.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Download, imageVector = Icons.Default.Download,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
tint = PrimaryBlue tint = PrimaryBlue
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "I Already Have a Seed Phrase", text = "I Already Have a Seed Phrase",
fontSize = 15.sp, fontSize = 15.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = PrimaryBlue color = PrimaryBlue
) )
} }
} }
Spacer(modifier = Modifier.weight(0.15f)) Spacer(modifier = Modifier.weight(0.15f))
} }
} }
@@ -264,70 +264,62 @@ fun WelcomeScreen(
@Composable @Composable
private fun CompactFeatureItem( private fun CompactFeatureItem(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String, text: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
textColor: Color textColor: Color
) { ) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue.copy(alpha = 0.12f)), .background(PrimaryBlue.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
Text( Text(
text = text, text = text,
fontSize = 13.sp, fontSize = 13.sp,
color = textColor.copy(alpha = 0.8f), color = textColor.copy(alpha = 0.8f),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
} }
@Composable @Composable
private fun FeatureItem( private fun FeatureItem(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String, text: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
textColor: Color textColor: Color
) { ) {
Row( Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box( Box(
modifier = Modifier modifier =
.size(40.dp) Modifier.size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(PrimaryBlue.copy(alpha = 0.15f)), .background(PrimaryBlue.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Text( Text(text = text, fontSize = 15.sp, color = textColor, fontWeight = FontWeight.Medium)
text = text,
fontSize = 15.sp,
color = textColor,
fontWeight = FontWeight.Medium
)
} }
} }

View File

@@ -534,38 +534,10 @@ fun ChatsListScreen(
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
// Connection status indicator // Username display
Row( if (accountName.isNotEmpty()) {
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))
Text( Text(
text = statusText, text = "@$accountName",
fontSize = 13.sp, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.85f) color = Color.White.copy(alpha = 0.85f)
) )
@@ -864,7 +836,9 @@ fun ChatsListScreen(
ProtocolState ProtocolState
.AUTHENTICATED .AUTHENTICATED
) )
textColor.copy(alpha = 0.6f) textColor.copy(
alpha = 0.6f
)
else else
textColor.copy( textColor.copy(
alpha = 0.5f alpha = 0.5f

View File

@@ -9,14 +9,13 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -24,13 +23,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlinx.coroutines.launch
/** /**
* 📨 BottomSheet для выбора чата при Forward сообщений * 📨 BottomSheet для выбора чата при Forward сообщений
* *
* Логика как в десктопной версии: * Логика как в десктопной версии:
* 1. Показывает список диалогов * 1. Показывает список диалогов
* 2. При выборе диалога - переходит в чат с сообщениями в Reply панели * 2. При выборе диалога - переходит в чат с сообщениями в Reply панели
@@ -38,25 +36,23 @@ import java.util.*
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ForwardChatPickerBottomSheet( fun ForwardChatPickerBottomSheet(
dialogs: List<DialogUiModel>, dialogs: List<DialogUiModel>,
isDarkTheme: Boolean, isDarkTheme: Boolean,
currentUserPublicKey: String, currentUserPublicKey: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onChatSelected: (String) -> Unit onChatSelected: (String) -> Unit
) { ) {
val sheetState = rememberModalBottomSheetState( val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
skipPartiallyExpanded = false
)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
val forwardMessages by ForwardManager.forwardMessages.collectAsState() val forwardMessages by ForwardManager.forwardMessages.collectAsState()
val messagesCount = forwardMessages.size val messagesCount = forwardMessages.size
// 🔥 Функция для красивого закрытия с анимацией // 🔥 Функция для красивого закрытия с анимацией
fun dismissWithAnimation() { fun dismissWithAnimation() {
scope.launch { scope.launch {
@@ -64,249 +60,232 @@ fun ForwardChatPickerBottomSheet(
onDismiss() onDismiss()
} }
} }
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { dismissWithAnimation() }, onDismissRequest = { dismissWithAnimation() },
sheetState = sheetState, sheetState = sheetState,
containerColor = backgroundColor, containerColor = backgroundColor,
dragHandle = { dragHandle = {
// Кастомный handle // Кастомный handle
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Box( Box(
modifier = Modifier modifier =
.width(36.dp) Modifier.width(36.dp)
.height(5.dp) .height(5.dp)
.clip(RoundedCornerShape(2.5.dp)) .clip(RoundedCornerShape(2.5.dp))
.background( .background(
if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6) if (isDarkTheme) Color(0xFF4A4A4A)
) else Color(0xFFD1D1D6)
) )
Spacer(modifier = Modifier.height(16.dp)) )
} Spacer(modifier = Modifier.height(16.dp))
}, }
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) },
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) { ) {
Column( Column(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) {
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
) {
// Header // Header
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
.fillMaxWidth() horizontalArrangement = Arrangement.SpaceBetween,
.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
// Иконка и заголовок // Иконка и заголовок
Row( Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically
) {
// 🔥 Красивая иконка Forward // 🔥 Красивая иконка Forward
Icon( Icon(
Icons.Filled.ArrowForward, Icons.Filled.ArrowForward,
contentDescription = null, contentDescription = null,
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier modifier = Modifier.size(24.dp)
.size(24.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column { Column {
Text( Text(
text = "Forward to", text = "Forward to",
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor color = textColor
) )
Text( Text(
text = "$messagesCount message${if (messagesCount > 1) "s" else ""} selected", text =
fontSize = 14.sp, "$messagesCount message${if (messagesCount > 1) "s" else ""} selected",
color = secondaryTextColor fontSize = 14.sp,
color = secondaryTextColor
) )
} }
} }
// Кнопка закрытия с анимацией // Кнопка закрытия с анимацией
IconButton(onClick = { dismissWithAnimation() }) { IconButton(onClick = { dismissWithAnimation() }) {
Icon( Icon(
Icons.Default.Close, Icons.Default.Close,
contentDescription = "Close", contentDescription = "Close",
tint = secondaryTextColor.copy(alpha = 0.6f) tint = secondaryTextColor.copy(alpha = 0.6f)
) )
} }
} }
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Divider( Divider(color = dividerColor, thickness = 0.5.dp)
color = dividerColor,
thickness = 0.5.dp
)
// Список диалогов // Список диалогов
if (dialogs.isEmpty()) { if (dialogs.isEmpty()) {
// Empty state // Empty state
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxWidth().height(200.dp),
.fillMaxWidth() contentAlignment = Alignment.Center
.height(200.dp),
contentAlignment = Alignment.Center
) { ) {
Column( Column(horizontalAlignment = Alignment.CenterHorizontally) {
horizontalAlignment = Alignment.CenterHorizontally
) {
Text( Text(
text = "No chats yet", text = "No chats yet",
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = secondaryTextColor color = secondaryTextColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Start a conversation first", text = "Start a conversation first",
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor.copy(alpha = 0.7f) color = secondaryTextColor.copy(alpha = 0.7f)
) )
} }
} }
} else { } else {
LazyColumn( LazyColumn(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.heightIn(min = 300.dp, max = 400.dp) // 🔥 Минимальная высота для лучшего UX .heightIn(
min = 300.dp,
max = 400.dp
) // 🔥 Минимальная высота для лучшего UX
) { ) {
items(dialogs, key = { it.opponentKey }) { dialog -> items(dialogs, key = { it.opponentKey }) { dialog ->
ForwardDialogItem( ForwardDialogItem(
dialog = dialog, dialog = dialog,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
isSavedMessages = dialog.opponentKey == currentUserPublicKey, isSavedMessages = dialog.opponentKey == currentUserPublicKey,
onClick = { onClick = { onChatSelected(dialog.opponentKey) }
onChatSelected(dialog.opponentKey)
}
) )
// Сепаратор между диалогами // Сепаратор между диалогами
if (dialog != dialogs.last()) { if (dialog != dialogs.last()) {
Divider( Divider(
modifier = Modifier.padding(start = 76.dp), modifier = Modifier.padding(start = 76.dp),
color = dividerColor, color = dividerColor,
thickness = 0.5.dp thickness = 0.5.dp
) )
} }
} }
} }
} }
// Нижний padding // Нижний padding
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }
} }
/** /** Элемент диалога в списке выбора для Forward */
* Элемент диалога в списке выбора для Forward
*/
@Composable @Composable
private fun ForwardDialogItem( private fun ForwardDialogItem(
dialog: DialogUiModel, dialog: DialogUiModel,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isSavedMessages: Boolean = false, isSavedMessages: Boolean = false,
onClick: () -> Unit onClick: () -> Unit
) { ) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val avatarColors = remember(dialog.opponentKey, isDarkTheme) { val avatarColors =
getAvatarColor(dialog.opponentKey, isDarkTheme) 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("")
} }
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( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar // Avatar
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background( .background(
if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f) if (isSavedMessages) PrimaryBlue.copy(alpha = 0.15f)
else avatarColors.backgroundColor else avatarColors.backgroundColor
), ),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = initials, text = initials,
color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor, color = if (isSavedMessages) PrimaryBlue else avatarColors.textColor,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp fontSize = 16.sp
) )
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
// Info // Info
Column( Column(modifier = Modifier.weight(1f)) {
modifier = Modifier.weight(1f)
) {
Text( Text(
text = displayName, text = displayName,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp, fontSize = 16.sp,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = if (isSavedMessages) "Your personal notes" else dialog.lastMessage.ifEmpty { "No messages" }, text =
fontSize = 14.sp, if (isSavedMessages) "Your personal notes"
color = secondaryTextColor, else dialog.lastMessage.ifEmpty { "No messages" },
maxLines = 1, fontSize = 14.sp,
overflow = TextOverflow.Ellipsis color = secondaryTextColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
// Online indicator // Online indicator
if (!isSavedMessages && dialog.isOnline == 1) { if (!isSavedMessages && dialog.isOnline == 1) {
Box( Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Color(0xFF34C759)))
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(Color(0xFF34C759))
)
} }
} }
} }

View File

@@ -1,5 +1,7 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.content.Context
import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.* 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.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalView 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,260 +36,251 @@ import com.rosetta.messenger.network.SearchUser
// Primary Blue color // Primary Blue color
private val PrimaryBlue = Color(0xFF54A9EB) private val PrimaryBlue = Color(0xFF54A9EB)
/** /** Отдельная страница поиска пользователей Хедер на всю ширину с полем ввода */
* Отдельная страница поиска пользователей
* Хедер на всю ширину с полем ввода
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchScreen( fun SearchScreen(
privateKeyHash: String, privateKeyHash: String,
currentUserPublicKey: String, currentUserPublicKey: String,
isDarkTheme: Boolean, isDarkTheme: Boolean,
protocolState: ProtocolState, protocolState: ProtocolState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onUserSelect: (SearchUser) -> Unit onUserSelect: (SearchUser) -> Unit
) { ) {
// Context и View для мгновенного закрытия клавиатуры // Context и View для мгновенного закрытия клавиатуры
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// 🔥 Функция мгновенного закрытия клавиатуры // 🔥 Функция мгновенного закрытия клавиатуры
val hideKeyboardInstantly: () -> Unit = { val hideKeyboardInstantly: () -> Unit = {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0) imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus() focusManager.clearFocus()
} }
// Цвета ТОЧНО как в ChatsListScreen // Цвета ТОЧНО как в ChatsListScreen
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a) val textColor = if (isDarkTheme) Color.White else Color(0xFF1a1a1a)
val secondaryTextColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d) val secondaryTextColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF6c757d)
val surfaceColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White val surfaceColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White
// Search ViewModel // Search ViewModel
val searchViewModel = remember { SearchUsersViewModel() } val searchViewModel = remember { SearchUsersViewModel() }
val searchQuery by searchViewModel.searchQuery.collectAsState() val searchQuery by searchViewModel.searchQuery.collectAsState()
val searchResults by searchViewModel.searchResults.collectAsState() val searchResults by searchViewModel.searchResults.collectAsState()
val isSearching by searchViewModel.isSearching.collectAsState() val isSearching by searchViewModel.isSearching.collectAsState()
// Recent users (не текстовые запросы, а пользователи) // Recent users (не текстовые запросы, а пользователи)
val recentUsers by RecentSearchesManager.recentUsers.collectAsState() val recentUsers by RecentSearchesManager.recentUsers.collectAsState()
// Preload Lottie composition for search animation // 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 // Устанавливаем аккаунт для RecentSearchesManager
LaunchedEffect(currentUserPublicKey) { LaunchedEffect(currentUserPublicKey) {
if (currentUserPublicKey.isNotEmpty()) { if (currentUserPublicKey.isNotEmpty()) {
RecentSearchesManager.setAccount(currentUserPublicKey) RecentSearchesManager.setAccount(currentUserPublicKey)
} }
} }
// Устанавливаем privateKeyHash // Устанавливаем privateKeyHash
LaunchedEffect(privateKeyHash) { LaunchedEffect(privateKeyHash) {
if (privateKeyHash.isNotEmpty()) { if (privateKeyHash.isNotEmpty()) {
searchViewModel.setPrivateKeyHash(privateKeyHash) searchViewModel.setPrivateKeyHash(privateKeyHash)
} }
} }
// Focus requester для автофокуса // Focus requester для автофокуса
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
// Автофокус при открытии // Автофокус при открытии
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
kotlinx.coroutines.delay(100) kotlinx.coroutines.delay(100)
focusRequester.requestFocus() focusRequester.requestFocus()
} }
Scaffold( Scaffold(
topBar = { topBar = {
// Кастомный header с полем ввода на всю ширину // Кастомный header с полем ввода на всю ширину
Surface( Surface(modifier = Modifier.fillMaxWidth(), color = backgroundColor) {
modifier = Modifier.fillMaxWidth(), Row(
color = backgroundColor modifier =
) { Modifier.fillMaxWidth()
Row( .statusBarsPadding()
modifier = Modifier .height(64.dp)
.fillMaxWidth() .padding(horizontal = 4.dp),
.statusBarsPadding() verticalAlignment = Alignment.CenterVertically
.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)
) { ) {
TextField( // Кнопка назад - с мгновенным закрытием клавиатуры
value = searchQuery, IconButton(
onValueChange = { searchViewModel.onSearchQueryChange(it) }, onClick = {
placeholder = { hideKeyboardInstantly()
Text( onBackClick()
"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( Icon(
Icons.Default.Clear, Icons.Default.ArrowBack,
contentDescription = "Clear", contentDescription = "Back",
tint = secondaryTextColor.copy(alpha = 0.6f) 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 -> ) { paddingValues ->
// Контент - показываем recent users если поле пустое, иначе результаты // Контент - показываем recent users если поле пустое, иначе результаты
Box( Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) { if (searchQuery.isEmpty() && recentUsers.isNotEmpty()) {
// Recent Users с аватарками // Recent Users с аватарками
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(vertical = 8.dp) contentPadding = PaddingValues(vertical = 8.dp)
) { ) {
item { item {
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
"Recent", "Recent",
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = secondaryTextColor color = secondaryTextColor
) )
TextButton(onClick = { RecentSearchesManager.clearAll() }) { TextButton(onClick = { RecentSearchesManager.clearAll() }) {
Text( Text("Clear All", fontSize = 13.sp, color = PrimaryBlue)
"Clear All",
fontSize = 13.sp,
color = PrimaryBlue
)
} }
} }
} }
items(recentUsers, key = { it.publicKey }) { user -> items(recentUsers, key = { it.publicKey }) { user ->
RecentUserItem( RecentUserItem(
user = user, user = user,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
textColor = textColor, textColor = textColor,
secondaryTextColor = secondaryTextColor, secondaryTextColor = secondaryTextColor,
onClick = { onClick = {
hideKeyboardInstantly() hideKeyboardInstantly()
RecentSearchesManager.addUser(user) RecentSearchesManager.addUser(user)
onUserSelect(user) onUserSelect(user)
}, },
onRemove = { onRemove = { RecentSearchesManager.removeUser(user.publicKey) }
RecentSearchesManager.removeUser(user.publicKey)
}
) )
} }
} }
} else { } else {
// Search Results // Search Results
// Проверяем, не ищет ли пользователь сам себя (Saved Messages) // Проверяем, не ищет ли пользователь сам себя (Saved Messages)
val isSavedMessagesSearch = searchQuery.trim().let { query -> val isSavedMessagesSearch =
query.equals(currentUserPublicKey, ignoreCase = true) || searchQuery.trim().let { query ->
query.equals(currentUserPublicKey.take(8), ignoreCase = true) || query.equals(currentUserPublicKey, ignoreCase = true) ||
query.equals(currentUserPublicKey.takeLast(8), ignoreCase = true) query.equals(currentUserPublicKey.take(8), ignoreCase = true) ||
} query.equals(
currentUserPublicKey.takeLast(8),
// Если ищем себя - показываем Saved Messages как первый результат ignoreCase = true
val resultsWithSavedMessages = if (isSavedMessagesSearch && searchResults.none { it.publicKey == currentUserPublicKey }) { )
listOf( }
SearchUser(
title = "Saved Messages", // Если ищем себя - показываем Saved Messages как первый результат
username = "", val resultsWithSavedMessages =
publicKey = currentUserPublicKey, if (isSavedMessagesSearch &&
verified = 0, searchResults.none { it.publicKey == currentUserPublicKey }
online = 1 ) {
) listOf(
) + searchResults.filter { it.publicKey != currentUserPublicKey } SearchUser(
} else { title = "Saved Messages",
searchResults username = "",
} publicKey = currentUserPublicKey,
verified = 0,
SearchResultsList( online = 1
searchResults = resultsWithSavedMessages, )
isSearching = isSearching, ) + searchResults.filter { it.publicKey != currentUserPublicKey }
currentUserPublicKey = currentUserPublicKey, } else {
isDarkTheme = isDarkTheme, searchResults
preloadedComposition = searchLottieComposition, }
onUserClick = { user ->
// Мгновенно закрываем клавиатуру SearchResultsList(
hideKeyboardInstantly() searchResults = resultsWithSavedMessages,
// Сохраняем пользователя в историю (кроме Saved Messages) isSearching = isSearching,
if (user.publicKey != currentUserPublicKey) { currentUserPublicKey = currentUserPublicKey,
RecentSearchesManager.addUser(user) 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 @Composable
private fun RecentUserItem( private fun RecentUserItem(
user: SearchUser, user: SearchUser,
isDarkTheme: Boolean, isDarkTheme: Boolean,
textColor: Color, textColor: Color,
secondaryTextColor: Color, secondaryTextColor: Color,
onClick: () -> Unit, onClick: () -> Unit,
onRemove: () -> Unit onRemove: () -> Unit
) { ) {
val displayName = user.title.ifEmpty { val displayName =
user.username.ifEmpty { user.title.ifEmpty { user.username.ifEmpty { user.publicKey.take(8) + "..." } }
user.publicKey.take(8) + "..."
}
}
// Используем getInitials из ChatsListScreen // Используем getInitials из ChatsListScreen
val initials = getInitials(displayName) val initials = getInitials(displayName)
// Используем getAvatarColor из ChatsListScreen для правильных цветов // Используем getAvatarColor из ChatsListScreen для правильных цветов
val avatarColors = getAvatarColor(user.publicKey, isDarkTheme) val avatarColors = getAvatarColor(user.publicKey, isDarkTheme)
Row( Row(
modifier = Modifier modifier =
.fillMaxWidth() Modifier.fillMaxWidth()
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Avatar // Avatar
Box( Box(
modifier = Modifier modifier =
.size(48.dp) Modifier.size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(avatarColors.backgroundColor), .background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = initials, text = initials,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = avatarColors.textColor color = avatarColors.textColor
) )
} }
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
// Name and username // Name and username
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = displayName, text = displayName,
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = textColor, color = textColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (user.verified != 0) { if (user.verified != 0) {
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Icon( Icon(
Icons.Default.Verified, Icons.Default.Verified,
contentDescription = "Verified", contentDescription = "Verified",
tint = PrimaryBlue, tint = PrimaryBlue,
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
} }
if (user.username.isNotEmpty()) { if (user.username.isNotEmpty()) {
Text( Text(
text = "@${user.username}", text = "@${user.username}",
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor, color = secondaryTextColor,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
} }
// Remove button // Remove button
IconButton( IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
onClick = onRemove,
modifier = Modifier.size(40.dp)
) {
Icon( Icon(
Icons.Default.Close, Icons.Default.Close,
contentDescription = "Remove", contentDescription = "Remove",
tint = secondaryTextColor.copy(alpha = 0.6f), tint = secondaryTextColor.copy(alpha = 0.6f),
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
} }