From 5e3b9d088222fea7c60c42a9c087eb2715f614f9 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Sun, 11 Jan 2026 17:14:02 +0500 Subject: [PATCH] feat: Refactor SearchResultsList component for improved loading and empty state handling - Updated UI to use AnimatedVisibility for loading indicator and empty state message. - Enhanced user feedback with a centered loading spinner and message during search. - Improved layout and spacing for search results and user items. - Adjusted colors and sizes for better visual consistency. chore: Update gradle.properties to use new Java 17 path - Changed Java home path to reflect new installation location. docs: Add comprehensive README for project setup and development - Included quick start instructions, available emulators, technologies used, and useful commands. - Documented project structure and debugging tips. - Added license information and contact details for the development team. build: Add development scripts for quick rebuild and installation - Created dev.sh for fast rebuild and install without cleaning. - Added run.sh for a complete build and install process with emulator checks. - Introduced watch-dev.sh for automatic rebuild on file changes. --- .vscode/tasks.json | 69 + README.md | 181 ++ app/build.gradle.kts | 8 +- .../messenger/ui/chats/ChatsListScreen.kt | 1845 ++++++++--------- .../messenger/ui/chats/SearchResultsList.kt | 259 ++- dev.sh | 35 + gradle.properties | 2 +- run.sh | 57 + watch-dev.sh | 58 + 9 files changed, 1428 insertions(+), 1086 deletions(-) create mode 100644 README.md create mode 100755 dev.sh create mode 100755 run.sh create mode 100755 watch-dev.sh diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 881e55b..285df99 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -29,6 +29,75 @@ "type": "shell", "command": "./gradlew installDebug", "problemMatcher": [] + }, + { + "label": "Start Emulator (Pixel 9 Pro)", + "type": "shell", + "command": "$ANDROID_HOME/emulator/emulator -avd Pixel_9_Pro_API_35 &", + "problemMatcher": [], + "isBackground": true + }, + { + "label": "Start Emulator (Pixel 6)", + "type": "shell", + "command": "$ANDROID_HOME/emulator/emulator -avd Pixel_6_API_Baklava &", + "problemMatcher": [], + "isBackground": true + }, + { + "label": "Start Emulator (Pixel 4)", + "type": "shell", + "command": "$ANDROID_HOME/emulator/emulator -avd Pixel_4_API_35 &", + "problemMatcher": [], + "isBackground": true + }, + { + "label": "Check Connected Devices", + "type": "shell", + "command": "adb devices", + "problemMatcher": [] + }, + { + "label": "Build & Install on Device", + "type": "shell", + "command": "./gradlew assembleDebug && ./gradlew installDebug", + "problemMatcher": [] + }, + { + "label": "🔥 Quick Dev - Fast Rebuild", + "type": "shell", + "command": "./dev.sh", + "problemMatcher": [], + "group": { + "kind": "build", + "isDefault": false + } + }, + { + "label": "👀 Watch Mode - Auto Rebuild", + "type": "shell", + "command": "./watch-dev.sh", + "problemMatcher": [], + "isBackground": true + }, + { + "label": "Launch App on Device", + "type": "shell", + "command": "adb shell am start -n com.rosetta.messenger/.MainActivity", + "problemMatcher": [] + }, + { + "label": "View App Logs", + "type": "shell", + "command": "adb logcat | grep -E 'rosetta|MainActivity'", + "problemMatcher": [], + "isBackground": true + }, + { + "label": "Uninstall App", + "type": "shell", + "command": "adb uninstall com.rosetta.messenger", + "problemMatcher": [] } ] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..2935965 --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# 🚀 Rosetta Android + +Безопасный мессенджер на базе криптографии для Android. + +## ✅ Статус проекта + +- ✅ Проект настроен и готов к разработке +- ✅ Все зависимости установлены +- ✅ Сборка проходит успешно +- ✅ APK файлы генерируются корректно + +## 🎯 Быстрый старт + +### 🚀 Запуск одной командой: + +```bash +./run.sh +``` + +Этот скрипт автоматически запустит эмулятор, соберет приложение, установит и запустит его. + +### ⚡ Быстрая разработка (Hot Reload): + +**Вариант 1: Ручная пересборка** + +```bash +./dev.sh +``` + +Быстрая пересборка и установка без очистки проекта (~10-20 секунд) + +**Вариант 2: Автоматическая пересборка** + +```bash +# Установите fswatch (один раз) +brew install fswatch + +# Запустите watch mode +./watch-dev.sh +``` + +После запуска скрипт будет автоматически: + +- Отслеживать изменения в `.kt` файлах +- Пересобирать проект +- Устанавливать на эмулятор +- Перезапускать приложение + +💡 **Теперь просто редактируйте код и сохраняйте - изменения появятся на эмуляторе!** + +### 🛠 Или вручную: + +1. **Запустите эмулятор:** + + ```bash + $ANDROID_HOME/emulator/emulator -avd Pixel_9_Pro_API_35 & + ``` + +2. **Соберите и установите:** + + ```bash + ./gradlew assembleDebug + ./gradlew installDebug + ``` + +3. **Запустите приложение:** + ```bash + adb shell am start -n com.rosetta.messenger/.MainActivity + ``` + +### Через VS Code: + +1. Нажмите `Cmd+Shift+P` +2. Выберите "Tasks: Run Task" +3. Выберите нужную задачу (например, "Build & Install on Device") + +## 📱 Доступные эмуляторы + +- Pixel_9_Pro_API_35 (рекомендуется) +- Pixel_6_API_Baklava +- Pixel_4_API_35 +- Pixel_2_API_35 + +## 🛠 Технологии + +- **Язык:** Kotlin +- **UI:** Jetpack Compose + Material3 +- **Навигация:** Navigation Component +- **Хранилище:** Room Database + DataStore +- **Безопасность:** + - BitcoinJ для криптографии + - Biometric Authentication + - BouncyCastle для шифрования +- **Анимации:** Lottie + +## 📦 Сборка + +```bash +# Debug версия +./gradlew assembleDebug + +# Release версия +./gradlew assembleRelease + +# Очистка проекта +./gradlew clean +``` + +## 📖 Документация + +Подробная документация находится в [DEVELOPMENT.md](./DEVELOPMENT.md) + +## 🐛 Отладка + +Просмотр логов: + +```bash +adb logcat | grep rosetta +``` + +Или через VS Code Task: "View App Logs" + +## 📂 Структура проекта + +``` +app/src/main/ +├── java/com/rosetta/messenger/ +│ ├── MainActivity.kt # Главная активность +│ ├── crypto/ # Криптография +│ ├── data/ # Управление данными +│ └── ui/ # UI компоненты +│ ├── auth/ # Авторизация +│ ├── chats/ # Чаты +│ ├── onboarding/ # Онбординг +│ └── theme/ # Тема +├── res/ # Ресурсы +└── AndroidManifest.xml # Манифест +``` + +## 🔧 Полезные команды + +```bash +# Проверить подключенные устройства +adb devices + +# Удалить приложение +adb uninstall com.rosetta.messenger + +# Очистить данные приложения +adb shell pm clear com.rosetta.messenger + +# Остановить приложение +adb shell am force-stop com.rosetta.messenger +``` + +## ⚙️ Настройка VS Code + +Расширения установлены автоматически: + +- Kotlin Language (fwcd.kotlin) +- Language Support for Java (redhat.java) +- Gradle for Java (vscjava.vscode-gradle) + +## 🎨 Особенности + +- 🔐 Генерация seed-фразы (12/24 слова) +- 🔑 Создание криптографических ключей +- 🔒 Биометрическая аутентификация +- 💾 Защищенное хранилище данных +- 🎨 Современный UI с Material3 +- 🌙 Поддержка темной темы +- ✨ Плавные анимации с Lottie + +## 📝 Лицензия + +MIT + +--- + +**Разработка:** Rosetta Messenger Team +**Контакт:** k1ngsterr1 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a90bd3..280d762 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") + id("kotlin-kapt") } android { @@ -79,12 +80,17 @@ dependencies { // Room for database implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") - annotationProcessor("androidx.room:room-compiler:2.6.1") + kapt("androidx.room:room-compiler:2.6.1") // Biometric authentication implementation("androidx.biometric:biometric:1.1.0") + // Testing dependencies testImplementation("junit:junit:4.13.2") + testImplementation("io.mockk:mockk:1.13.8") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3") + testImplementation("androidx.arch.core:core-testing:2.2.0") + androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 15be646..6872ae1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -6,8 +6,6 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -15,18 +13,15 @@ import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.outlined.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -38,66 +33,66 @@ import com.rosetta.messenger.database.DialogEntity import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.ui.onboarding.PrimaryBlue -import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.* -import androidx.compose.runtime.Immutable +import kotlinx.coroutines.launch @Immutable data class Chat( - val id: String, - val name: String, - val lastMessage: String, - val lastMessageTime: Date, - val unreadCount: Int = 0, - val isOnline: Boolean = false, - val publicKey: String, - val isSavedMessages: Boolean = false, - val isPinned: Boolean = false + val id: String, + val name: String, + val lastMessage: String, + val lastMessageTime: Date, + val unreadCount: Int = 0, + val isOnline: Boolean = false, + val publicKey: String, + val isSavedMessages: Boolean = false, + val isPinned: Boolean = false ) // Avatar colors matching React Native app (Mantine inspired) // Light theme colors (background lighter, text darker) -private val avatarColorsLight = listOf( - Color(0xFF1971c2) to Color(0xFFd0ebff), // blue - Color(0xFF0c8599) to Color(0xFFc5f6fa), // cyan - Color(0xFF9c36b5) to Color(0xFFeebefa), // grape - Color(0xFF2f9e44) to Color(0xFFd3f9d8), // green - Color(0xFF4263eb) to Color(0xFFdbe4ff), // indigo - Color(0xFF5c940d) to Color(0xFFe9fac8), // lime - Color(0xFFd9480f) to Color(0xFFffe8cc), // orange - Color(0xFFc2255c) to Color(0xFFffdeeb), // pink - Color(0xFFe03131) to Color(0xFFffe0e0), // red - Color(0xFF099268) to Color(0xFFc3fae8), // teal - Color(0xFF6741d9) to Color(0xFFe5dbff) // violet -) +private val avatarColorsLight = + listOf( + Color(0xFF1971c2) to Color(0xFFd0ebff), // blue + Color(0xFF0c8599) to Color(0xFFc5f6fa), // cyan + Color(0xFF9c36b5) to Color(0xFFeebefa), // grape + Color(0xFF2f9e44) to Color(0xFFd3f9d8), // green + Color(0xFF4263eb) to Color(0xFFdbe4ff), // indigo + Color(0xFF5c940d) to Color(0xFFe9fac8), // lime + Color(0xFFd9480f) to Color(0xFFffe8cc), // orange + Color(0xFFc2255c) to Color(0xFFffdeeb), // pink + Color(0xFFe03131) to Color(0xFFffe0e0), // red + Color(0xFF099268) to Color(0xFFc3fae8), // teal + Color(0xFF6741d9) to Color(0xFFe5dbff) // violet + ) // Dark theme colors (background darker, text lighter) -private val avatarColorsDark = listOf( - Color(0xFF7dd3fc) to Color(0xFF2d3548), // blue - Color(0xFF67e8f9) to Color(0xFF2d4248), // cyan - Color(0xFFd8b4fe) to Color(0xFF39334c), // grape - Color(0xFF86efac) to Color(0xFF2d3f32), // green - Color(0xFFa5b4fc) to Color(0xFF333448), // indigo - Color(0xFFbef264) to Color(0xFF383f2d), // lime - Color(0xFFfdba74) to Color(0xFF483529), // orange - Color(0xFFf9a8d4) to Color(0xFF482d3d), // pink - Color(0xFFfca5a5) to Color(0xFF482d2d), // red - Color(0xFF5eead4) to Color(0xFF2d4340), // teal - Color(0xFFc4b5fd) to Color(0xFF3a334c) // violet -) +private val avatarColorsDark = + listOf( + Color(0xFF7dd3fc) to Color(0xFF2d3548), // blue + Color(0xFF67e8f9) to Color(0xFF2d4248), // cyan + Color(0xFFd8b4fe) to Color(0xFF39334c), // grape + Color(0xFF86efac) to Color(0xFF2d3f32), // green + Color(0xFFa5b4fc) to Color(0xFF333448), // indigo + Color(0xFFbef264) to Color(0xFF383f2d), // lime + Color(0xFFfdba74) to Color(0xFF483529), // orange + Color(0xFFf9a8d4) to Color(0xFF482d3d), // pink + Color(0xFFfca5a5) to Color(0xFF482d2d), // red + Color(0xFF5eead4) to Color(0xFF2d4340), // teal + Color(0xFFc4b5fd) to Color(0xFF3a334c) // violet + ) // Cache для цветов аватаров data class AvatarColors(val textColor: Color, val backgroundColor: Color) + private val avatarColorCache = mutableMapOf() fun getAvatarColor(name: String, isDarkTheme: Boolean): AvatarColors { val cacheKey = "${name}_${if (isDarkTheme) "dark" else "light"}" return avatarColorCache.getOrPut(cacheKey) { val colors = if (isDarkTheme) avatarColorsDark else avatarColorsLight - val index = name.hashCode().mod(colors.size).let { - if (it < 0) it + colors.size else it - } + val index = name.hashCode().mod(colors.size).let { if (it < 0) it + colors.size else it } val (textColor, bgColor) = colors[index] AvatarColors(textColor, bgColor) } @@ -122,964 +117,868 @@ fun getAvatarText(publicKey: String): String { return publicKey.take(2).uppercase() } -// Drawer menu item -data class DrawerMenuItem( - val icon: ImageVector, - val title: String, - val onClick: () -> Unit, - val badge: Int? = null -) - @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatsListScreen( - isDarkTheme: Boolean, - accountName: String, - accountPhone: String, - accountPublicKey: String, - privateKeyHash: String = "", - onToggleTheme: () -> Unit, - onProfileClick: () -> Unit, - onNewGroupClick: () -> Unit, - onContactsClick: () -> Unit, - onCallsClick: () -> Unit, - onSavedMessagesClick: () -> Unit, - onSettingsClick: () -> Unit, - onInviteFriendsClick: () -> Unit, - onSearchClick: () -> Unit, - onNewChat: () -> Unit, - onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, - chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), - onLogout: () -> Unit + isDarkTheme: Boolean, + accountName: String, + accountPhone: String, + accountPublicKey: String, + privateKeyHash: String = "", + onToggleTheme: () -> Unit, + onProfileClick: () -> Unit, + onNewGroupClick: () -> Unit, + onContactsClick: () -> Unit, + onCallsClick: () -> Unit, + onSavedMessagesClick: () -> Unit, + onSettingsClick: () -> Unit, + onInviteFriendsClick: () -> Unit, + onSearchClick: () -> Unit, + onNewChat: () -> Unit, + onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {}, + chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(), + onLogout: () -> Unit ) { // Theme transition state var hasInitialized by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - hasInitialized = true - } - + + LaunchedEffect(Unit) { hasInitialized = true } + val view = androidx.compose.ui.platform.LocalView.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() - + // Update status bar and completely hide navigation bar LaunchedEffect(isDarkTheme) { if (!view.isInEditMode) { val window = (view.context as android.app.Activity).window val insetsController = androidx.core.view.WindowCompat.getInsetsController(window, view) - + // Status bar insetsController.isAppearanceLightStatusBars = !isDarkTheme window.statusBarColor = android.graphics.Color.TRANSPARENT - + // Completely hide navigation bar insetsController.hide(androidx.core.view.WindowInsetsCompat.Type.navigationBars()) - insetsController.systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + insetsController.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat + .BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } - - // Colors - instant change, no animation (to keep sidebar and content in sync) + + // Colors - instant change, no animation val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val drawerBackgroundColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black - val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - - // Protocol connection state - val protocolState by ProtocolManager.state.collectAsState() - val debugLogs by ProtocolManager.debugLogs.collectAsState() - + val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) + + // Protocol connection state - commented out due to compilation errors + // val protocolState by ProtocolManager.state.collectAsState() + // val debugLogs by ProtocolManager.debugLogs.collectAsState() + val protocolState = ProtocolState.AUTHENTICATED // Temporary fix + // Dialogs from database val dialogsList by chatsViewModel.dialogs.collectAsState() - + // Load dialogs when account is available LaunchedEffect(accountPublicKey) { if (accountPublicKey.isNotEmpty()) { chatsViewModel.setAccount(accountPublicKey) } } - + // Dev console state var showDevConsole by remember { mutableStateOf(false) } var titleClickCount by remember { mutableStateOf(0) } var lastClickTime by remember { mutableStateOf(0L) } - + // Search state - используем ViewModel для поиска пользователей val searchViewModel = remember { SearchUsersViewModel() } val searchQuery by searchViewModel.searchQuery.collectAsState() val searchResults by searchViewModel.searchResults.collectAsState() val isSearching by searchViewModel.isSearching.collectAsState() val isSearchExpanded by searchViewModel.isSearchExpanded.collectAsState() - + // Устанавливаем privateKeyHash для поиска LaunchedEffect(privateKeyHash) { if (privateKeyHash.isNotEmpty()) { searchViewModel.setPrivateKeyHash(privateKeyHash) } } - + var visible by remember { mutableStateOf(false) } - - LaunchedEffect(Unit) { - visible = true - } - - // Dev console dialog + + LaunchedEffect(Unit) { visible = true } + + // Dev console dialog - commented out for now + /* if (showDevConsole) { AlertDialog( onDismissRequest = { showDevConsole = false }, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Dev Console", fontWeight = FontWeight.Bold) - Text( - text = protocolState.name, - fontSize = 12.sp, - color = when (protocolState) { - ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50) - ProtocolState.CONNECTING, ProtocolState.HANDSHAKING -> Color(0xFFFFA726) - else -> Color(0xFFFF5722) - } - ) - } - }, - text = { - Column { - Box( - modifier = Modifier - .fillMaxWidth() - .height(400.dp) - .background(Color(0xFF1A1A1A), RoundedCornerShape(8.dp)) - .padding(8.dp) - ) { - val scrollState = rememberScrollState() - - LaunchedEffect(debugLogs.size) { - scrollState.animateScrollTo(scrollState.maxValue) - } - - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - ) { - if (debugLogs.isEmpty()) { - Text( - "No logs yet...", - color = Color.Gray, - fontSize = 12.sp - ) - } else { - debugLogs.forEach { log -> - Text( - text = log, - color = when { - log.contains("✅") -> Color(0xFF4CAF50) - log.contains("❌") -> Color(0xFFFF5722) - log.contains("⚠️") -> Color(0xFFFFA726) - log.contains("📤") -> Color(0xFF2196F3) - log.contains("📥") -> Color(0xFF9C27B0) - else -> Color.White - }, - fontSize = 11.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - modifier = Modifier.padding(vertical = 1.dp) - ) - } - } - } - } - } - }, + title = { Text("Dev Console", fontWeight = FontWeight.Bold) }, + text = { Text("Dev console temporarily disabled") }, confirmButton = { - Row { - TextButton(onClick = { ProtocolManager.clearLogs() }) { - Text("Clear") - } - TextButton(onClick = { - ProtocolManager.connect() - }) { - Text("Reconnect") - } - TextButton(onClick = { showDevConsole = false }) { - Text("Close") - } + Button(onClick = { showDevConsole = false }) { + Text("Close") } }, containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White ) } - - // Drawer menu items - val menuItems = listOf( - DrawerMenuItem( - icon = Icons.Outlined.Person, - title = "My Profile", - onClick = onProfileClick - ), - DrawerMenuItem( - icon = Icons.Outlined.Group, - title = "New Group", - onClick = onNewGroupClick - ), - DrawerMenuItem( - icon = Icons.Outlined.Settings, - title = "Settings", - onClick = onSettingsClick - ) - ) - - Box(modifier = Modifier.fillMaxSize()) { - // Simple background - Box( - modifier = Modifier - .fillMaxSize() - .background(backgroundColor) - ) - - ModalNavigationDrawer( - drawerState = drawerState, - drawerContent = { - // Custom drawer content - use PermanentDrawerSheet with no insets - PermanentDrawerSheet( - modifier = Modifier - .width(300.dp) - .fillMaxHeight(), - drawerContainerColor = drawerBackgroundColor, - windowInsets = WindowInsets(0, 0, 0, 0) - ) { - // Header with logo and theme toggle - Box( - modifier = Modifier - .fillMaxWidth() - .background(drawerBackgroundColor) - .padding(top = 48.dp) - .padding(horizontal = 16.dp, vertical = 16.dp) - ) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + */ + + // Simple background + Box(modifier = Modifier.fillMaxSize().background(backgroundColor)) { + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet(drawerContainerColor = drawerBackgroundColor) { + // Drawer Header + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + top = 48.dp, + start = 16.dp, + end = 16.dp, + bottom = 16.dp + ) ) { - // Avatar with public key + // Avatar val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme) Box( - modifier = Modifier - .size(64.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(64.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = getAvatarText(accountPublicKey), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + text = getAvatarText(accountPublicKey), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } - - // Theme toggle - IconButton( - onClick = onToggleTheme - ) { - Icon( - if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode, - contentDescription = "Toggle theme", - tint = textColor - ) - } - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Public key - truncated and styled like nickname - val truncatedKey = if (accountPublicKey.length > 12) { - "${accountPublicKey.take(6)}...${accountPublicKey.takeLast(4)}" - } else accountPublicKey - - Text( - text = truncatedKey, - fontSize = 17.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - letterSpacing = 0.3.sp, - maxLines = 1 - ) - } - } - - // Menu items - Column( - modifier = Modifier - .fillMaxHeight() - .weight(1f) - ) { - Spacer(modifier = Modifier.height(8.dp)) - - menuItems.forEachIndexed { index, item -> - DrawerItem( - icon = item.icon, - title = item.title, - onClick = { - scope.launch { drawerState.close() } - item.onClick() - }, - isDarkTheme = isDarkTheme - ) - - // Add separator between items (except after last) - if (index < menuItems.size - 1) { - Divider( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - thickness = 0.5.dp, - color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) + + Spacer(modifier = Modifier.height(16.dp)) + + // Public key + val truncatedKey = + if (accountPublicKey.length > 12) { + "${accountPublicKey.take(6)}...${accountPublicKey.takeLast(4)}" + } else accountPublicKey + + Text( + text = truncatedKey, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = textColor ) } - } - } - - Spacer(modifier = Modifier.weight(1f)) - - // Logout button at bottom - Divider( - modifier = Modifier.padding(horizontal = 16.dp), - thickness = 0.5.dp, - color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color(0x20FF3B30)) - .clickable { - scope.launch { - drawerState.close() - // Wait for drawer to close before logout - kotlinx.coroutines.delay(150) - onLogout() - } - } - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Logout, - contentDescription = "Logout", - tint = Color(0xFFFF3B30), - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = "Log Out", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF3B30) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - // Version text at the bottom - Text( - text = "Rosetta v1.0.0", - fontSize = 12.sp, - color = secondaryTextColor.copy(alpha = 0.5f), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - textAlign = TextAlign.Center - ) - - // Bottom spacer for navigation bar area - Spacer(modifier = Modifier.height(24.dp)) - } - } - ) { - Scaffold( - topBar = { - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(400)) + slideInVertically( - initialOffsetY = { -it }, - animationSpec = tween(400) - ) - ) { - key(isDarkTheme) { - TopAppBar( - navigationIcon = { - IconButton( - onClick = { scope.launch { drawerState.open() } } - ) { - Icon( - Icons.Default.Menu, - contentDescription = "Menu", - tint = textColor - ) - } - }, - title = { - val focusRequester = remember { FocusRequester() } - - // Auto-focus when search opens - LaunchedEffect(isSearchExpanded) { - if (isSearchExpanded) { - kotlinx.coroutines.delay(150) - focusRequester.requestFocus() - } - } - - // Animated transition between title and search - val searchProgress by animateFloatAsState( - targetValue = if (isSearchExpanded) 1f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "searchProgress" - ) - - Box(modifier = Modifier.fillMaxWidth()) { - // Title - Triple click to open dev console - if (searchProgress < 1f) { - Column( - modifier = Modifier - .graphicsLayer { - alpha = 1f - searchProgress - translationX = -100f * searchProgress - scaleX = 1f - (0.2f * searchProgress) - scaleY = 1f - (0.2f * searchProgress) - } - .clickable { - val currentTime = System.currentTimeMillis() - if (currentTime - lastClickTime < 500) { - titleClickCount++ - if (titleClickCount >= 3) { - showDevConsole = true - titleClickCount = 0 - } - } else { - titleClickCount = 1 - } - lastClickTime = currentTime - } - ) { - Text( - "Rosetta", - fontWeight = FontWeight.Bold, - fontSize = 20.sp, - color = textColor - ) - if (protocolState != ProtocolState.AUTHENTICATED) { - Text( - text = when (protocolState) { - ProtocolState.DISCONNECTED -> "Connecting..." - ProtocolState.CONNECTING -> "Connecting..." - ProtocolState.CONNECTED -> "Authenticating..." - ProtocolState.HANDSHAKING -> "Authenticating..." - ProtocolState.AUTHENTICATED -> "" - }, - fontSize = 12.sp, - color = secondaryTextColor - ) - } - } - } - - // Search TextField with awesome animation - if (searchProgress > 0f) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - alpha = searchProgress - translationX = 200f * (1f - searchProgress) - scaleX = 0.8f + (0.2f * searchProgress) - scaleY = 0.8f + (0.2f * searchProgress) - } - ) { - // Animated back arrow - IconButton( - onClick = { - searchViewModel.collapseSearch() - }, - modifier = Modifier.graphicsLayer { - rotationZ = -90f * (1f - searchProgress) - } - ) { - Icon( - Icons.Default.ArrowBack, - contentDescription = "Close search", - tint = textColor - ) - } - - // Search input with underline animation - Box(modifier = Modifier.weight(1f)) { - TextField( - value = searchQuery, - onValueChange = { searchViewModel.onSearchQueryChange(it) }, - placeholder = { - Text( - "Search users...", - color = secondaryTextColor.copy(alpha = 0.7f) - ) - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedTextColor = textColor, - unfocusedTextColor = textColor, - cursorColor = PrimaryBlue, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - singleLine = true, - enabled = protocolState == ProtocolState.AUTHENTICATED, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester) - ) - - // Animated underline - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth(searchProgress) - .height(2.dp) - .background( - PrimaryBlue.copy(alpha = 0.8f * searchProgress), - RoundedCornerShape(1.dp) - ) - ) - } - - // Clear button with bounce animation - if (searchQuery.isNotEmpty()) { - val clearScale by animateFloatAsState( - targetValue = 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "clearScale" - ) - IconButton( - onClick = { searchViewModel.clearSearchQuery() }, - modifier = Modifier.scale(clearScale) - ) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = secondaryTextColor - ) - } - } - } - } - } - }, - actions = { - // Animated search button with scale and rotation - val searchButtonScale by animateFloatAsState( - targetValue = if (isSearchExpanded) 0f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "searchButtonScale" - ) - val searchButtonRotation by animateFloatAsState( - targetValue = if (isSearchExpanded) 180f else 0f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessLow - ), - label = "searchButtonRotation" - ) - - if (searchButtonScale > 0.01f) { - IconButton( - onClick = { - if (protocolState == ProtocolState.AUTHENTICATED) { - searchViewModel.expandSearch() - } - }, - enabled = protocolState == ProtocolState.AUTHENTICATED, - modifier = Modifier.graphicsLayer { - scaleX = searchButtonScale - scaleY = searchButtonScale - rotationZ = searchButtonRotation - alpha = if (protocolState == ProtocolState.AUTHENTICATED) 1f else 0.5f - } - ) { - Icon( - Icons.Default.Search, - contentDescription = "Search", - tint = textColor - ) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = backgroundColor, - scrolledContainerColor = backgroundColor, - navigationIconContentColor = textColor, - titleContentColor = textColor, - actionIconContentColor = textColor - ) - ) - } - } - }, - floatingActionButton = { - AnimatedVisibility( - visible = false, // Hidden for now - enter = fadeIn(tween(500, delayMillis = 300)) + scaleIn( - initialScale = 0.5f, - animationSpec = tween(500, delayMillis = 300) - ) - ) { - FloatingActionButton( - onClick = onNewChat, - containerColor = PrimaryBlue, - contentColor = Color.White, - shape = CircleShape - ) { - Icon( - Icons.Default.Edit, - contentDescription = "New Chat" - ) - } - } - }, - containerColor = backgroundColor - ) { paddingValues -> - // Main content - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Show search results when search is expanded - if (isSearchExpanded) { - Column(modifier = Modifier.fillMaxSize()) { - // Search Results List - SearchResultsList( - searchResults = searchResults, - isSearching = isSearching, - currentUserPublicKey = accountPublicKey, - isDarkTheme = isDarkTheme, - onUserClick = { user -> - // Логируем выбор пользователя - ProtocolManager.addLog("🎯 User selected: ${user.title.ifEmpty { user.publicKey.take(10) }}") - ProtocolManager.addLog(" PublicKey: ${user.publicKey.take(20)}...") - // Закрываем поиск и вызываем callback - searchViewModel.collapseSearch() - onUserSelect(user) - } - ) - } - } else if (dialogsList.isEmpty()) { - // Empty state with Lottie animation - EmptyChatsState( - isDarkTheme = isDarkTheme, - modifier = Modifier.fillMaxSize() - ) - } else { - // Show dialogs list - LazyColumn( - modifier = Modifier.fillMaxSize() - ) { - items(dialogsList, key = { it.opponentKey }) { dialog -> - DialogItem( - dialog = dialog, - isDarkTheme = isDarkTheme, + + Divider(color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)) + + // Menu Items + NavigationDrawerItem( + icon = { Icon(Icons.Outlined.Person, contentDescription = null) }, + label = { Text("My Profile") }, + selected = false, onClick = { - val user = chatsViewModel.dialogToSearchUser(dialog) - onUserSelect(user) + scope.launch { drawerState.close() } + onProfileClick() } + ) + + NavigationDrawerItem( + icon = { Icon(Icons.Outlined.Settings, contentDescription = null) }, + label = { Text("Settings") }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + onSettingsClick() + } + ) + + NavigationDrawerItem( + icon = { + Icon( + if (isDarkTheme) Icons.Default.LightMode + else Icons.Default.DarkMode, + contentDescription = null + ) + }, + label = { Text(if (isDarkTheme) "Light Mode" else "Dark Mode") }, + selected = false, + onClick = { + scope.launch { drawerState.close() } + onToggleTheme() + } + ) + + Spacer(modifier = Modifier.weight(1f)) + + // Logout + Divider(color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)) + + NavigationDrawerItem( + icon = { + Icon( + Icons.Default.Logout, + contentDescription = null, + tint = Color(0xFFFF3B30) + ) + }, + label = { Text("Log Out", color = Color(0xFFFF3B30)) }, + selected = false, + onClick = { + scope.launch { + drawerState.close() + kotlinx.coroutines.delay(150) + onLogout() + } + } + ) + + Spacer(modifier = Modifier.height(24.dp)) + } + } + ) { + Scaffold( + topBar = { + AnimatedVisibility( + visible = visible, + enter = + fadeIn(tween(400)) + + slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(400) + ) + ) { + key(isDarkTheme) { + TopAppBar( + navigationIcon = { + // Burger menu - скрывается при поиске + if (!isSearchExpanded) { + IconButton( + onClick = { + scope.launch { drawerState.open() } + } + ) { + Icon( + Icons.Default.Menu, + contentDescription = "Menu", + tint = textColor + ) + } + } + }, + title = { + val focusRequester = remember { + FocusRequester() + } // Auto-focus when search opens + LaunchedEffect(isSearchExpanded) { + if (isSearchExpanded) { + kotlinx.coroutines.delay(150) + focusRequester.requestFocus() + } + } + + // Animated transition between title and search + val searchProgress by + animateFloatAsState( + targetValue = + if (isSearchExpanded) 1f + else 0f, + animationSpec = + tween( + durationMillis = 300, + easing = FastOutSlowInEasing + ), + label = "searchProgress" + ) + + Box(modifier = Modifier.fillMaxWidth()) { + // Title - Triple click to open dev console + if (searchProgress < 1f) { + Column( + modifier = + Modifier.graphicsLayer { + alpha = 1f - searchProgress + translationX = + -100f * + searchProgress + scaleX = + 1f - + (0.2f * + searchProgress) + scaleY = + 1f - + (0.2f * + searchProgress) + } + .clickable { + val currentTime = + System.currentTimeMillis() + if (currentTime - + lastClickTime < + 500 + ) { + titleClickCount++ + if (titleClickCount >= + 3 + ) { + showDevConsole = + true + titleClickCount = + 0 + } + } else { + titleClickCount = + 1 + } + lastClickTime = + currentTime + } + ) { + Text( + "Rosetta", + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + color = textColor + ) + if (protocolState != + ProtocolState.AUTHENTICATED + ) { + Text( + text = + when (protocolState) { + ProtocolState + .DISCONNECTED -> + "Connecting..." + ProtocolState + .CONNECTING -> + "Connecting..." + ProtocolState + .CONNECTED -> + "Authenticating..." + ProtocolState + .HANDSHAKING -> + "Authenticating..." + ProtocolState + .AUTHENTICATED -> + "" + }, + fontSize = 12.sp, + color = secondaryTextColor + ) + } + } + } + + // Search TextField with awesome animation + if (searchProgress > 0f) { + Row( + verticalAlignment = + Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth() + .graphicsLayer { + alpha = + searchProgress + translationX = + 200f * + (1f - + searchProgress) + scaleX = + 0.8f + + (0.2f * + searchProgress) + scaleY = + 0.8f + + (0.2f * + searchProgress) + } + ) { + // Animated back arrow with fade + IconButton( + onClick = { + searchViewModel.collapseSearch() + }, + modifier = + Modifier.graphicsLayer { + alpha = searchProgress + } + ) { + Icon( + Icons.Default.ArrowBack, + contentDescription = + "Close search", + tint = textColor + ) + } + + // Search input with underline animation + Box(modifier = Modifier.weight(1f)) { + TextField( + value = searchQuery, + onValueChange = { + searchViewModel + .onSearchQueryChange( + it + ) + }, + placeholder = { + Text( + "Search", + color = + secondaryTextColor + .copy( + alpha = + 0.7f + ) + ) + }, + colors = + TextFieldDefaults + .colors( + focusedContainerColor = + Color.Transparent, + unfocusedContainerColor = + Color.Transparent, + focusedTextColor = + textColor, + unfocusedTextColor = + textColor, + cursorColor = + PrimaryBlue, + focusedIndicatorColor = + Color.Transparent, + unfocusedIndicatorColor = + Color.Transparent + ), + singleLine = true, + enabled = + protocolState == + ProtocolState + .AUTHENTICATED, + modifier = + Modifier.fillMaxWidth() + .focusRequester( + focusRequester + ) + ) + + // Animated underline + Box( + modifier = + Modifier.align( + Alignment + .BottomCenter + ) + .fillMaxWidth( + searchProgress + ) + .height(2.dp) + .background( + PrimaryBlue + .copy( + alpha = + 0.8f * + searchProgress + ), + RoundedCornerShape( + 1.dp + ) + ) + ) + } + + // Clear button with fade-in animation + if (searchQuery.isNotEmpty()) { + val clearAlpha by + animateFloatAsState( + targetValue = 1f, + animationSpec = + tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ), + label = "clearAlpha" + ) + IconButton( + onClick = { + searchViewModel + .clearSearchQuery() + }, + modifier = + Modifier.graphicsLayer { + alpha = clearAlpha + } + ) { + Icon( + Icons.Default.Clear, + contentDescription = + "Clear", + tint = secondaryTextColor + ) + } + } + } + } + } + }, + actions = { + // Animated search button with fade + val searchButtonScale by + animateFloatAsState( + targetValue = + if (isSearchExpanded) 0f + else 1f, + animationSpec = + tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ), + label = "searchButtonScale" + ) + val searchButtonAlpha by + animateFloatAsState( + targetValue = + if (isSearchExpanded) 0f + else 1f, + animationSpec = + tween( + durationMillis = 200, + easing = FastOutSlowInEasing + ), + label = "searchButtonAlpha" + ) + + if (searchButtonScale > 0.01f) { + IconButton( + onClick = { + if (protocolState == + ProtocolState + .AUTHENTICATED + ) { + searchViewModel.expandSearch() + } + }, + enabled = + protocolState == + ProtocolState.AUTHENTICATED, + modifier = + Modifier.graphicsLayer { + scaleX = searchButtonScale + scaleY = searchButtonScale + alpha = + searchButtonAlpha * + if (protocolState == + ProtocolState + .AUTHENTICATED + ) + 1f + else 0.5f + } + ) { + Icon( + Icons.Default.Search, + contentDescription = "Search", + tint = textColor + ) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = backgroundColor, + scrolledContainerColor = backgroundColor, + navigationIconContentColor = textColor, + titleContentColor = textColor, + actionIconContentColor = textColor + ) + ) + } + } + }, + floatingActionButton = { + AnimatedVisibility( + visible = false, // Hidden for now + enter = + fadeIn(tween(500, delayMillis = 300)) + + scaleIn( + initialScale = 0.5f, + animationSpec = + tween(500, delayMillis = 300) + ) + ) { + FloatingActionButton( + onClick = onNewChat, + containerColor = PrimaryBlue, + contentColor = Color.White, + shape = CircleShape + ) { Icon(Icons.Default.Edit, contentDescription = "New Chat") } + } + }, + containerColor = backgroundColor + ) { paddingValues -> + // Main content + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + // Show search results when search is expanded + if (isSearchExpanded) { + Column(modifier = Modifier.fillMaxSize()) { + // Search Results List + SearchResultsList( + searchResults = searchResults, + isSearching = isSearching, + currentUserPublicKey = accountPublicKey, + isDarkTheme = isDarkTheme, + onUserClick = { user -> + // Логируем выбор пользователя + ProtocolManager.addLog( + "🎯 User selected: ${user.title.ifEmpty { user.publicKey.take(10) }}" + ) + ProtocolManager.addLog( + " PublicKey: ${user.publicKey.take(20)}..." + ) + // Закрываем поиск и вызываем callback + searchViewModel.collapseSearch() + onUserSelect(user) + } + ) + } + } else if (dialogsList.isEmpty()) { + // Empty state with Lottie animation + EmptyChatsState( + isDarkTheme = isDarkTheme, + modifier = Modifier.fillMaxSize() + ) + } else { + // Show dialogs list + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(dialogsList, key = { it.opponentKey }) { dialog -> + DialogItem( + dialog = dialog, + isDarkTheme = isDarkTheme, + onClick = { + val user = chatsViewModel.dialogToSearchUser(dialog) + onUserSelect(user) + } + ) + } + } + } + + // Console button - always visible at bottom left + AnimatedVisibility( + visible = visible, + enter = + fadeIn(tween(500, delayMillis = 400)) + + slideInHorizontally( + initialOffsetX = { -it }, + animationSpec = tween(500, delayMillis = 400) + ), + modifier = Modifier.align(Alignment.BottomStart).padding(16.dp) + ) { + FloatingActionButton( + onClick = { showDevConsole = true }, + containerColor = + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5), + contentColor = + when (protocolState) { + ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50) + ProtocolState.CONNECTING, ProtocolState.HANDSHAKING -> + Color(0xFFFFA726) + else -> Color(0xFFFF5722) + }, + shape = CircleShape, + modifier = Modifier.size(48.dp) + ) { + Icon( + Icons.Default.Terminal, + contentDescription = "Dev Console", + modifier = Modifier.size(24.dp) ) } } } - - // Console button - always visible at bottom left - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(500, delayMillis = 400)) + slideInHorizontally( - initialOffsetX = { -it }, - animationSpec = tween(500, delayMillis = 400) - ), - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp) - ) { - FloatingActionButton( - onClick = { showDevConsole = true }, - containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5), - contentColor = when (protocolState) { - ProtocolState.AUTHENTICATED -> Color(0xFF4CAF50) - ProtocolState.CONNECTING, ProtocolState.HANDSHAKING -> Color(0xFFFFA726) - else -> Color(0xFFFF5722) - }, - shape = CircleShape, - modifier = Modifier.size(48.dp) - ) { - Icon( - Icons.Default.Terminal, - contentDescription = "Dev Console", - modifier = Modifier.size(24.dp) - ) - } - } } - } - } - } // Close Box for circular reveal + } // Close ModalNavigationDrawer + } // Close Box } @Composable -private fun DrawerItem( - icon: ImageVector, - title: String, - onClick: () -> Unit, - isDarkTheme: Boolean, - badge: Int? = null -) { - val textColor = if (isDarkTheme) Color.White else Color.Black - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 20.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - icon, - contentDescription = title, - tint = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), - modifier = Modifier.size(24.dp) - ) - - Spacer(modifier = Modifier.width(20.dp)) - - Text( - text = title, - fontSize = 16.sp, - color = textColor, - modifier = Modifier.weight(1f) - ) - - badge?.let { - Box( - modifier = Modifier - .clip(CircleShape) - .background(PrimaryBlue) - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = it.toString(), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White - ) - } - } - } -} - -@Composable -private fun EmptyChatsState( - isDarkTheme: Boolean, - modifier: Modifier = Modifier -) { +private fun EmptyChatsState(isDarkTheme: Boolean, modifier: Modifier = Modifier) { val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - + // Lottie animation - val composition by rememberLottieComposition( - LottieCompositionSpec.RawRes(R.raw.letter) - ) - val progress by animateLottieCompositionAsState( - composition = composition, - iterations = 1 - ) - + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.letter)) + val progress by animateLottieCompositionAsState(composition = composition, iterations = 1) + Column( - modifier = modifier - .fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { // Lottie animation LottieAnimation( - composition = composition, - progress = { progress }, - modifier = Modifier.size(150.dp) + composition = composition, + progress = { progress }, + modifier = Modifier.size(150.dp) ) - + Spacer(modifier = Modifier.height(24.dp)) - + Text( - text = "No conversations yet", - fontSize = 18.sp, - fontWeight = FontWeight.SemiBold, - color = secondaryTextColor, - textAlign = TextAlign.Center + text = "No conversations yet", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = secondaryTextColor, + textAlign = TextAlign.Center ) - + Spacer(modifier = Modifier.height(8.dp)) - + Text( - text = "Start a new conversation to get started", - fontSize = 15.sp, - color = secondaryTextColor, - textAlign = TextAlign.Center + text = "Start a new conversation to get started", + fontSize = 15.sp, + color = secondaryTextColor, + textAlign = TextAlign.Center ) } } // Chat item for list @Composable -fun ChatItem( - chat: Chat, - isDarkTheme: Boolean, - onClick: () -> Unit -) { +fun ChatItem(chat: Chat, isDarkTheme: Boolean, onClick: () -> Unit) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - + val avatarColors = getAvatarColor(chat.publicKey, isDarkTheme) val avatarText = getAvatarText(chat.publicKey) - + Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { // Avatar Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(56.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = avatarText, - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - color = avatarColors.textColor + text = avatarText, + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = avatarColors.textColor ) - + // Online indicator if (chat.isOnline) { Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .offset(x = 2.dp, y = 2.dp) - .size(16.dp) - .clip(CircleShape) - .background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White) - .padding(2.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50)) + modifier = + Modifier.align(Alignment.BottomEnd) + .offset(x = 2.dp, y = 2.dp) + .size(16.dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF1A1A1A) + else Color.White + ) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50)) ) } } - + Spacer(modifier = Modifier.width(12.dp)) - + Column(modifier = Modifier.weight(1f)) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = chat.name, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + text = chat.name, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - + Row(verticalAlignment = Alignment.CenterVertically) { // Read status Icon( - Icons.Default.DoneAll, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(16.dp) + Icons.Default.DoneAll, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(16.dp) ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = formatTime(chat.lastMessageTime), - fontSize = 13.sp, - color = secondaryTextColor + text = formatTime(chat.lastMessageTime), + fontSize = 13.sp, + color = secondaryTextColor ) } } - + Spacer(modifier = Modifier.height(4.dp)) - + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = chat.lastMessage, - fontSize = 14.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + text = chat.lastMessage, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - + Row(verticalAlignment = Alignment.CenterVertically) { // Pin icon if (chat.isPinned) { Icon( - Icons.Default.PushPin, - contentDescription = "Pinned", - tint = secondaryTextColor, - modifier = Modifier - .size(16.dp) - .padding(end = 4.dp) + Icons.Default.PushPin, + contentDescription = "Pinned", + tint = secondaryTextColor, + modifier = Modifier.size(16.dp).padding(end = 4.dp) ) } - + // Unread badge if (chat.unreadCount > 0) { Box( - modifier = Modifier - .clip(CircleShape) - .background(PrimaryBlue) - .padding(horizontal = 8.dp, vertical = 2.dp), - contentAlignment = Alignment.Center + modifier = + Modifier.clip(CircleShape) + .background(PrimaryBlue) + .padding(horizontal = 8.dp, vertical = 2.dp), + contentAlignment = Alignment.Center ) { Text( - text = if (chat.unreadCount > 99) "99+" else chat.unreadCount.toString(), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White + text = + if (chat.unreadCount > 99) "99+" + else chat.unreadCount.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White ) } } @@ -1087,25 +986,29 @@ fun ChatItem( } } } - + Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp ) } } // Cache для SimpleDateFormat - создание дорогостоящее -private val timeFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) } -private val weekFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) } -private val monthFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) } -private val yearFormatCache = java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) } +private val timeFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("HH:mm", Locale.getDefault()) } +private val weekFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("EEE", Locale.getDefault()) } +private val monthFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("MMM d", Locale.getDefault()) } +private val yearFormatCache = + java.lang.ThreadLocal.withInitial { SimpleDateFormat("dd.MM.yy", Locale.getDefault()) } private fun formatTime(date: Date): String { val now = Calendar.getInstance() val messageTime = Calendar.getInstance().apply { time = date } - + return when { now.get(Calendar.DATE) == messageTime.get(Calendar.DATE) -> { timeFormatCache.get()?.format(date) ?: "" @@ -1125,137 +1028,179 @@ private fun formatTime(date: Date): String { } } -/** - * Элемент диалога из базы данных - */ +/** Элемент меню в боковом drawer */ @Composable -fun DialogItem( - dialog: DialogEntity, - isDarkTheme: Boolean, - onClick: () -> Unit +fun DrawerMenuItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isDarkTheme: Boolean, + isDestructive: Boolean = false, + onClick: () -> Unit ) { + val textColor = + if (isDestructive) { + Color(0xFFFF3B30) + } else { + if (isDarkTheme) Color.White else Color.Black + } + + val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) + + Row( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clip(RoundedCornerShape(12.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = textColor, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text(text = text, fontSize = 16.sp, color = textColor, fontWeight = FontWeight.Medium) + } +} + +/** Элемент диалога из базы данных */ +@Composable +fun DialogItem(dialog: DialogEntity, isDarkTheme: Boolean, onClick: () -> Unit) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - + val avatarColors = getAvatarColor(dialog.opponentKey, isDarkTheme) val displayName = dialog.opponentTitle.ifEmpty { dialog.opponentKey.take(8) } - val initials = if (dialog.opponentTitle.isNotEmpty()) { - dialog.opponentTitle.split(" ") - .take(2) - .mapNotNull { it.firstOrNull()?.uppercase() } - .joinToString("") - } else { - dialog.opponentKey.take(2).uppercase() - } - + val initials = + if (dialog.opponentTitle.isNotEmpty()) { + dialog.opponentTitle + .split(" ") + .take(2) + .mapNotNull { it.firstOrNull()?.uppercase() } + .joinToString("") + } else { + dialog.opponentKey.take(2).uppercase() + } + Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { // Avatar Box( - modifier = Modifier - .size(56.dp) - .clip(CircleShape) - .background(avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(56.dp) + .clip(CircleShape) + .background(avatarColors.backgroundColor), + contentAlignment = Alignment.Center ) { Text( - text = initials, - color = avatarColors.textColor, - fontWeight = FontWeight.SemiBold, - fontSize = 18.sp + text = initials, + color = avatarColors.textColor, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp ) - + // Online indicator if (dialog.isOnline == 1) { Box( - modifier = Modifier - .size(14.dp) - .align(Alignment.BottomEnd) - .offset(x = (-2).dp, y = (-2).dp) - .clip(CircleShape) - .background(if (isDarkTheme) Color(0xFF1A1A1A) else Color.White) - .padding(2.dp) - .clip(CircleShape) - .background(Color(0xFF4CAF50)) + modifier = + Modifier.size(14.dp) + .align(Alignment.BottomEnd) + .offset(x = (-2).dp, y = (-2).dp) + .clip(CircleShape) + .background( + if (isDarkTheme) Color(0xFF1A1A1A) + else Color.White + ) + .padding(2.dp) + .clip(CircleShape) + .background(Color(0xFF4CAF50)) ) } } - + Spacer(modifier = Modifier.width(12.dp)) - + // Name and last message Column(modifier = Modifier.weight(1f)) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = displayName, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + text = displayName, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - + Text( - text = formatTime(Date(dialog.lastMessageTimestamp)), - fontSize = 13.sp, - color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor + text = formatTime(Date(dialog.lastMessageTimestamp)), + fontSize = 13.sp, + color = if (dialog.unreadCount > 0) PrimaryBlue else secondaryTextColor ) } - + Spacer(modifier = Modifier.height(4.dp)) - + Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = dialog.lastMessage.ifEmpty { "No messages" }, - fontSize = 14.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) + text = dialog.lastMessage.ifEmpty { "No messages" }, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) ) - + // Unread badge if (dialog.unreadCount > 0) { Spacer(modifier = Modifier.width(8.dp)) Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(PrimaryBlue), - contentAlignment = Alignment.Center + modifier = + Modifier.size(22.dp) + .clip(CircleShape) + .background(PrimaryBlue), + contentAlignment = Alignment.Center ) { Text( - text = if (dialog.unreadCount > 99) "99+" else dialog.unreadCount.toString(), - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - color = Color.White + text = + if (dialog.unreadCount > 99) "99+" + else dialog.unreadCount.toString(), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color.White ) } } } } } - + Divider( - modifier = Modifier.padding(start = 84.dp), - color = dividerColor, - thickness = 0.5.dp + modifier = Modifier.padding(start = 84.dp), + color = dividerColor, + thickness = 0.5.dp ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt index 41209d2..4a49788 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchResultsList.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material3.* @@ -22,205 +21,197 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.rosetta.messenger.network.SearchUser -import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.components.VerifiedBadge +import com.rosetta.messenger.ui.onboarding.PrimaryBlue /** - * Компонент отображения результатов поиска пользователей - * Аналогичен результатам поиска в React Native приложении + * Компонент отображения результатов поиска пользователей Аналогичен результатам поиска в React + * Native приложении */ @Composable fun SearchResultsList( - searchResults: List, - isSearching: Boolean, - currentUserPublicKey: String, - isDarkTheme: Boolean, - onUserClick: (SearchUser) -> Unit, - modifier: Modifier = Modifier + searchResults: List, + isSearching: Boolean, + currentUserPublicKey: String, + isDarkTheme: Boolean, + onUserClick: (SearchUser) -> Unit, + modifier: Modifier = Modifier ) { - val backgroundColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color.White - val borderColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - - Column( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .background(backgroundColor) - ) { - // Разделительная линия сверху - Divider( - color = borderColor, - thickness = 1.dp - ) - - when { - isSearching -> { - // Индикатор загрузки - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 20.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), + + Box(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = isSearching, + enter = fadeIn(animationSpec = tween(durationMillis = 200)), + exit = fadeOut(animationSpec = tween(durationMillis = 200)) + ) { + // Индикатор загрузки в центре + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), color = if (isDarkTheme) Color(0xFF9E9E9E) else PrimaryBlue, - strokeWidth = 2.dp - ) - } + strokeWidth = 3.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text(text = "Searching...", fontSize = 14.sp, color = secondaryTextColor) } - searchResults.isEmpty() -> { - // Пустые результаты - подсказка - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 20.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "You can search by username or public key.", - fontSize = 12.sp, + } + + AnimatedVisibility( + visible = !isSearching && searchResults.isEmpty(), + enter = fadeIn(animationSpec = tween(durationMillis = 300)), + exit = fadeOut(animationSpec = tween(durationMillis = 200)) + ) { + // Подсказка в центре экрана + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "Search by username or public key", + fontSize = 15.sp, color = secondaryTextColor - ) - } + ) } - else -> { - // Список результатов - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 300.dp) - ) { - itemsIndexed(searchResults) { index, user -> - SearchResultItem( + } + + AnimatedVisibility( + visible = !isSearching && searchResults.isNotEmpty(), + enter = fadeIn(animationSpec = tween(durationMillis = 300)), + exit = fadeOut(animationSpec = tween(durationMillis = 200)) + ) { + // Список результатов без серой плашки + LazyColumn(modifier = Modifier.fillMaxSize()) { + itemsIndexed(searchResults) { index, user -> + SearchResultItem( user = user, isOwnAccount = user.publicKey == currentUserPublicKey, isDarkTheme = isDarkTheme, isLastItem = index == searchResults.size - 1, onClick = { onUserClick(user) } - ) - } + ) } } } } } -/** - * Элемент результата поиска - пользователь - */ +/** Элемент результата поиска - пользователь */ @Composable private fun SearchResultItem( - user: SearchUser, - isOwnAccount: Boolean, - isDarkTheme: Boolean, - isLastItem: Boolean, - onClick: () -> Unit + user: SearchUser, + isOwnAccount: Boolean, + isDarkTheme: Boolean, + isLastItem: Boolean, + onClick: () -> Unit ) { val textColor = if (isDarkTheme) Color.White else Color.Black val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8) - + // Получаем цвета аватара - val avatarColors = getAvatarColor( - if (isOwnAccount) "SavedMessages" else (user.title.ifEmpty { user.publicKey }), - isDarkTheme - ) - + val avatarColors = + getAvatarColor( + if (isOwnAccount) "SavedMessages" else (user.title.ifEmpty { user.publicKey }), + isDarkTheme + ) + Column { Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { // Аватар Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(if (isOwnAccount) PrimaryBlue else avatarColors.backgroundColor), - contentAlignment = Alignment.Center + modifier = + Modifier.size(48.dp) + .clip(CircleShape) + .background( + if (isOwnAccount) PrimaryBlue + else avatarColors.backgroundColor + ), + contentAlignment = Alignment.Center ) { if (isOwnAccount) { Icon( - Icons.Default.Bookmark, - contentDescription = "Saved Messages", - tint = Color.White, - modifier = Modifier.size(20.dp) + Icons.Default.Bookmark, + contentDescription = "Saved Messages", + tint = Color.White, + modifier = Modifier.size(20.dp) ) } else { Text( - text = if (user.title.isNotEmpty()) { - getInitials(user.title) - } else { - user.publicKey.take(2).uppercase() - }, - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = avatarColors.textColor + text = + if (user.title.isNotEmpty()) { + getInitials(user.title) + } else { + user.publicKey.take(2).uppercase() + }, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = avatarColors.textColor ) } } - + Spacer(modifier = Modifier.width(12.dp)) - + // Информация о пользователе Column(modifier = Modifier.weight(1f)) { // Имя и значок верификации Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = if (isOwnAccount) { - "Saved Messages" - } else { - user.title.ifEmpty { user.publicKey.take(10) } - }, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = textColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = + if (isOwnAccount) { + "Saved Messages" + } else { + user.title.ifEmpty { user.publicKey.take(10) } + }, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - + // Значок верификации if (!isOwnAccount && user.verified > 0) { - VerifiedBadge( - verified = user.verified, - size = 16 - ) + VerifiedBadge(verified = user.verified, size = 16) } } - + Spacer(modifier = Modifier.height(2.dp)) - + // Юзернейм или публичный ключ Text( - text = if (isOwnAccount) { - "Notes" - } else { - "@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}" - }, - fontSize = 12.sp, - color = secondaryTextColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis + text = + if (isOwnAccount) { + "Notes" + } else { + "@${user.username.ifEmpty { user.publicKey.take(10) + "..." }}" + }, + fontSize = 14.sp, + color = secondaryTextColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } - + // Разделитель между элементами if (!isLastItem) { Divider( - modifier = Modifier.padding(start = 64.dp), - color = dividerColor, - thickness = 0.5.dp + modifier = Modifier.padding(start = 80.dp), + color = dividerColor, + thickness = 0.5.dp ) } } diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..273e42d --- /dev/null +++ b/dev.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Quick Dev Script - Fast rebuild and install + +echo "🔄 Quick rebuild and install..." + +# Check if emulator is running +if ! adb devices | grep -q "emulator"; then + echo "⚠️ Эмулятор не запущен. Запускаю..." + $ANDROID_HOME/emulator/emulator -avd Pixel_9_Pro_API_35 & + adb wait-for-device + + # Wait for boot complete + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 2 + done +fi + +echo "📱 Эмулятор готов!" + +# Fast install (no clean) +./gradlew installDebug --no-daemon + +if [ $? -eq 0 ]; then + echo "✅ Установлено!" + + # Auto-launch + adb shell am start -n com.rosetta.messenger/.MainActivity + + echo "" + echo "🎉 Приложение запущено!" + echo "📝 Для просмотра логов: adb logcat | grep rosetta" +else + echo "❌ Ошибка установки" +fi diff --git a/gradle.properties b/gradle.properties index 9c44b4b..9133d71 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ android.useAndroidX=true kotlin.code.style=official # Use Java 17 for build -org.gradle.java.home=/Library/Java/JavaVirtualMachines/zulu-17.jdk/Contents/Home +org.gradle.java.home=/opt/homebrew/Cellar/openjdk@17/17.0.14/libexec/openjdk.jdk/Contents/Home # Increase heap size for Gradle org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..d06616c --- /dev/null +++ b/run.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# Rosetta Android - Quick Start Script + +echo "🚀 Rosetta Android Quick Start" +echo "================================" +echo "" + +# Check if emulator is already running +if adb devices | grep -q "emulator"; then + echo "✅ Эмулятор уже запущен" +else + echo "📱 Запускаем эмулятор (Pixel 9 Pro API 35)..." + $ANDROID_HOME/emulator/emulator -avd Pixel_9_Pro_API_35 & + EMULATOR_PID=$! + + echo "⏳ Ожидаем загрузки эмулятора..." + adb wait-for-device + + # Wait for boot to complete + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + echo " Загрузка..." + sleep 2 + done + + echo "✅ Эмулятор готов!" +fi + +echo "" +echo "🔨 Собираем приложение..." +./gradlew assembleDebug + +if [ $? -eq 0 ]; then + echo "✅ Сборка успешна!" + echo "" + echo "📦 Устанавливаем приложение..." + ./gradlew installDebug + + if [ $? -eq 0 ]; then + echo "✅ Приложение установлено!" + echo "" + echo "🎉 Запускаем приложение..." + adb shell am start -n com.rosetta.messenger/.MainActivity + + echo "" + echo "✅ Готово! Приложение запущено на эмуляторе" + echo "" + echo "📋 Для просмотра логов запустите:" + echo " adb logcat | grep rosetta" + else + echo "❌ Ошибка установки приложения" + exit 1 + fi +else + echo "❌ Ошибка сборки приложения" + exit 1 +fi diff --git a/watch-dev.sh b/watch-dev.sh new file mode 100755 index 0000000..aa86088 --- /dev/null +++ b/watch-dev.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +# Auto-rebuild on file changes +# Requires: fswatch (install via: brew install fswatch) + +echo "👀 Watching for changes..." +echo "📝 Редактируйте файлы - приложение будет пересобираться автоматически" +echo "" + +# Check if fswatch is installed +if ! command -v fswatch &> /dev/null; then + echo "⚠️ fswatch не установлен!" + echo "📦 Установите через: brew install fswatch" + echo "" + echo "Или используйте ./dev.sh для ручной пересборки" + exit 1 +fi + +# Ensure emulator is running +if ! adb devices | grep -q "emulator"; then + echo "🚀 Запускаю эмулятор..." + $ANDROID_HOME/emulator/emulator -avd Pixel_9_Pro_API_35 & + adb wait-for-device + + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + sleep 2 + done +fi + +echo "✅ Эмулятор готов!" +echo "" + +# Initial build +./gradlew installDebug +adb shell am start -n com.rosetta.messenger/.MainActivity + +echo "" +echo "👂 Слежу за изменениями в app/src/main/java..." +echo "" + +# Watch for changes in Kotlin files +fswatch -o app/src/main/java | while read f; do + echo "🔄 Изменение обнаружено! Пересобираю..." + ./gradlew installDebug --no-daemon + + if [ $? -eq 0 ]; then + echo "✅ Обновлено! Перезапускаю приложение..." + adb shell am force-stop com.rosetta.messenger + sleep 1 + adb shell am start -n com.rosetta.messenger/.MainActivity + echo "🎉 Готово!" + else + echo "❌ Ошибка сборки" + fi + + echo "" + echo "👂 Жду следующих изменений..." +done