feat: Integrate Firebase Cloud Messaging for push notifications; add service to handle token and message reception

This commit is contained in:
2026-01-16 23:06:41 +05:00
parent 7750f450e8
commit 431e3755c6
14 changed files with 1317 additions and 234 deletions

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("com.google.gms.google-services")
}
android {
@@ -110,6 +111,11 @@ dependencies {
// Baseline Profiles for startup performance
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
// Firebase Cloud Messaging
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
implementation("com.google.firebase:firebase-messaging-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
// Testing dependencies
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk:1.13.8")

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "123456789012",
"project_id": "rosetta-messenger",
"storage_bucket": "rosetta-messenger.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:123456789012:android:abcdef1234567890",
"android_client_info": {
"package_name": "com.rosetta.messenger"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -16,6 +17,7 @@
android:theme="@style/Theme.RosettaAndroid"
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -28,6 +30,25 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Firebase Cloud Messaging Service -->
<service
android:name=".push.RosettaFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- Firebase notification icon (optional, for better looking notifications) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_launcher_foreground" />
<!-- Firebase notification color (optional) -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/primary_blue" />
</application>
</manifest>

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -27,10 +28,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.firebase.FirebaseApp
import com.google.firebase.messaging.FirebaseMessaging
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.network.PacketPushToken
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow
@@ -49,6 +54,10 @@ class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager
companion object {
private const val TAG = "MainActivity"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -60,6 +69,9 @@ class MainActivity : ComponentActivity() {
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
ProtocolManager.initialize(this)
// 🔔 Инициализируем Firebase для push-уведомлений
initializeFirebase()
// 🚀 Предзагружаем эмодзи в фоне для мгновенного открытия пикера
// Используем новый оптимизированный кэш
OptimizedEmojiCache.preload(this)
@@ -153,7 +165,7 @@ class MainActivity : ComponentActivity() {
}
)
}
"auth_new", "auth_new", "auth_unlock" -> {
"auth_new", "auth_unlock" -> {
AuthFlow(
isDarkTheme = isDarkTheme,
hasExistingAccount = screen == "auth_unlock",
@@ -223,6 +235,47 @@ class MainActivity : ComponentActivity() {
}
}
}
/**
* 🔔 Инициализация Firebase Cloud Messaging
*/
private fun initializeFirebase() {
try {
// Инициализируем Firebase
FirebaseApp.initializeApp(this)
// Получаем FCM токен
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.e(TAG, "❌ Failed to get FCM token", task.exception)
return@addOnCompleteListener
}
val token = task.result
Log.d(TAG, "🔔 FCM token (short): ${token?.take(20)}...")
Log.d(TAG, "🔔 FCM token (FULL): $token")
// Сохраняем токен локально
token?.let { saveFcmToken(it) }
// TODO: Отправляем токен на сервер если аккаунт залогинен
// token?.let { sendFcmTokenToServer(it) }
}
Log.d(TAG, "✅ Firebase initialized successfully")
} catch (e: Exception) {
Log.e(TAG, "❌ Error initializing Firebase", e)
}
}
/**
* Сохранить FCM токен в SharedPreferences
*/
private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply()
Log.d(TAG, "💾 FCM token saved locally")
}
}
@Composable
@@ -333,12 +386,12 @@ fun MainScreen(
}
},
label = "screenNavigation"
) { (user, isSearchOpen, _) ->
) { (currentUser, isSearchOpen, _) ->
when {
user != null -> {
currentUser != null -> {
// Экран чата
ChatDetailScreen(
user = user,
user = currentUser,
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
isDarkTheme = isDarkTheme,
@@ -365,9 +418,9 @@ fun MainScreen(
isDarkTheme = isDarkTheme,
protocolState = protocolState,
onBackClick = { showSearchScreen = false },
onUserSelect = { user ->
onUserSelect = { selectedSearchUser ->
showSearchScreen = false
selectedUser = user
selectedUser = selectedSearchUser
}
)
}
@@ -394,7 +447,14 @@ fun MainScreen(
// TODO: Navigate to calls
},
onSavedMessagesClick = {
// TODO: Navigate to saved messages
// Открываем чат с самим собой (Saved Messages)
selectedUser = SearchUser(
title = "Saved Messages",
username = "",
publicKey = accountPublicKey,
verified = 0,
online = 1
)
},
onSettingsClick = {
// TODO: Navigate to settings
@@ -408,8 +468,8 @@ fun MainScreen(
onNewChat = {
// TODO: Show new chat screen
},
onUserSelect = { user ->
selectedUser = user
onUserSelect = { selectedChatUser ->
selectedUser = selectedChatUser
},
onLogout = onLogout
)
@@ -417,3 +477,4 @@ fun MainScreen(
}
}
}

View File

@@ -452,3 +452,33 @@ class PacketChunk : Packet() {
return stream
}
}
/**
* Push Token packet (ID: 0x0A)
* Отправка FCM/APNS токена на сервер для push-уведомлений
*/
class PacketPushToken : Packet() {
var privateKey: String = ""
var publicKey: String = ""
var pushToken: String = ""
var platform: String = "android" // "android" или "ios"
override fun getPacketId(): Int = 0x0A
override fun receive(stream: Stream) {
privateKey = stream.readString()
publicKey = stream.readString()
pushToken = stream.readString()
platform = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeString(privateKey)
stream.writeString(publicKey)
stream.writeString(pushToken)
stream.writeString(platform)
return stream
}
}

View File

@@ -0,0 +1,210 @@
package com.rosetta.messenger.push
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Firebase Cloud Messaging Service для обработки push-уведомлений
*
* Обрабатывает:
* - Получение нового FCM токена
* - Получение push-уведомлений о новых сообщениях
* - Отображение уведомлений
*/
class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object {
private const val TAG = "RosettaFCM"
private const val CHANNEL_ID = "rosetta_messages"
private const val CHANNEL_NAME = "Messages"
private const val NOTIFICATION_ID = 1
}
/**
* Вызывается когда получен новый FCM токен
* Отправляем его на сервер через протокол
*/
override fun onNewToken(token: String) {
super.onNewToken(token)
Log.d(TAG, "🔔 New FCM token (short): ${token.take(20)}...")
Log.d(TAG, "🔔 New FCM token (FULL): $token")
// Сохраняем токен локально
saveFcmToken(token)
// TODO: Отправляем токен на сервер если аккаунт уже залогинен
/*
serviceScope.launch {
try {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getCurrentAccount()
if (currentAccount != null) {
Log.d(TAG, "📤 Sending FCM token to server for account: ${currentAccount.publicKey.take(10)}...")
// Отправляем через протокол
val packet = PacketPushToken().apply {
this.privateKey = CryptoManager.generatePrivateKeyHash(currentAccount.privateKey)
this.publicKey = currentAccount.publicKey
this.pushToken = token
this.platform = "android"
}
ProtocolManager.send(packet)
Log.d(TAG, "✅ FCM token sent to server")
}
} catch (e: Exception) {
Log.e(TAG, "❌ Error sending FCM token to server", e)
}
}
*/
}
/**
* Вызывается когда получено push-уведомление
*/
override fun onMessageReceived(remoteMessage: RemoteMessage) {
super.onMessageReceived(remoteMessage)
Log.d(TAG, "📬 Push notification received from: ${remoteMessage.from}")
// Обрабатываем data payload
remoteMessage.data.isNotEmpty().let {
Log.d(TAG, "📦 Message data payload: ${remoteMessage.data}")
val type = remoteMessage.data["type"]
val senderPublicKey = remoteMessage.data["sender_public_key"]
val senderName = remoteMessage.data["sender_name"] ?: senderPublicKey?.take(10) ?: "Unknown"
val messagePreview = remoteMessage.data["message_preview"] ?: "New message"
when (type) {
"new_message" -> {
// Показываем уведомление о новом сообщении
showMessageNotification(senderPublicKey, senderName, messagePreview)
}
"message_read" -> {
// Сообщение прочитано - можно обновить UI если приложение открыто
Log.d(TAG, "📖 Message read by $senderPublicKey")
}
else -> {
Log.d(TAG, "⚠️ Unknown notification type: $type")
}
}
}
// Обрабатываем notification payload (если есть)
remoteMessage.notification?.let {
Log.d(TAG, "📨 Message Notification Body: ${it.body}")
showSimpleNotification(it.title ?: "Rosetta", it.body ?: "New message")
}
}
/**
* Показать уведомление о новом сообщении
*/
private fun showMessageNotification(senderPublicKey: String?, senderName: String, messagePreview: String) {
createNotificationChannel()
// Intent для открытия чата
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("open_chat", senderPublicKey)
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(senderName)
.setContentText(messagePreview)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
}
/**
* Показать простое уведомление
*/
private fun showSimpleNotification(title: String, body: String) {
createNotificationChannel()
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.build()
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
}
/**
* Создать notification channel для Android 8+
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for new messages"
enableVibration(true)
}
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
/**
* Сохранить FCM токен в SharedPreferences
*/
private fun saveFcmToken(token: String) {
val prefs = getSharedPreferences("rosetta_prefs", Context.MODE_PRIVATE)
prefs.edit().putString("fcm_token", token).apply()
Log.d(TAG, "💾 FCM token saved locally")
}
}

View File

@@ -358,9 +358,6 @@ fun ChatDetailScreen(
val chatTitle =
if (isSavedMessages) "Saved Messages"
else user.title.ifEmpty { user.publicKey.take(10) }
// Состояние показа логов
var showLogs by remember { mutableStateOf(false) }
// 📨 Forward: показывать ли выбор чата
var showForwardPicker by remember { mutableStateOf(false) }
@@ -375,12 +372,7 @@ fun ChatDetailScreen(
chatsListViewModel.setAccount(currentUserPublicKey, currentUserPrivateKey)
}
}
// 🚀 Собираем логи ТОЛЬКО когда они показываются - иначе каждый лог вызывает перекомпозицию!
val debugLogs = if (showLogs) {
com.rosetta.messenger.network.ProtocolManager.debugLogs.collectAsState().value
} else {
emptyList()
}
// Состояние выпадающего меню
var showMenu by remember { mutableStateOf(false) }
@@ -935,39 +927,7 @@ fun ChatDetailScreen(
else Color.Black.copy(alpha = 0.08f)
)
}
// Debug Logs
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
Icon(
Icons.Default.BugReport,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(14.dp))
Text(
"Debug Logs",
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
},
onClick = {
showMenu = false
showLogs = true
},
modifier = Modifier.padding(horizontal = 8.dp)
.background(inputBackgroundColor),
colors = MenuDefaults.itemColors(
textColor = textColor
)
)
}
}
}
@@ -1390,58 +1350,6 @@ fun ChatDetailScreen(
}
} // Закрытие Box с fade-in
// Диалог логов
if (showLogs) {
AlertDialog(
onDismissRequest = { showLogs = false },
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White,
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text("Debug Logs (${debugLogs.size})", fontWeight = FontWeight.Bold, color = textColor)
IconButton(
onClick = {
com.rosetta.messenger.network.ProtocolManager.clearLogs()
}
) { Icon(Icons.Default.Delete, contentDescription = "Clear", tint = Color(0xFFFF3B30)) }
}
},
text = {
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 500.dp)) {
items(debugLogs.reversed()) { log ->
val logColor = when {
log.contains("") || log.contains("Error") -> Color(0xFFFF3B30)
log.contains("") -> Color(0xFF38B24D)
log.contains("📤") -> PrimaryBlue
log.contains("📥") -> Color(0xFFFF9500)
log.contains("⚠️") -> Color(0xFFFFCC00)
else -> if (isDarkTheme) Color.White.copy(alpha = 0.8f) else Color.Black.copy(alpha = 0.8f)
}
Text(
text = log,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
color = logColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
if (debugLogs.isEmpty()) {
item {
Text(
text = "No logs yet. Try sending a message.",
color = secondaryTextColor,
fontSize = 12.sp
)
}
}
}
},
confirmButton = { TextButton(onClick = { showLogs = false }) { Text("Close", color = PrimaryBlue) } }
)
}
// Диалог подтверждения удаления чата
if (showDeleteConfirm) {

View File

@@ -281,129 +281,290 @@ fun ChatsListScreen(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = drawerBackgroundColor,
modifier = Modifier.width(280.dp)
drawerContainerColor = Color.Transparent,
windowInsets = WindowInsets(0), // 🎨 Убираем системные отступы - drawer идет до верха
modifier = Modifier.width(300.dp)
) {
// Drawer Header
Column(
modifier =
Modifier.fillMaxWidth()
.padding(
top = 48.dp,
start = 16.dp,
end = 16.dp,
bottom = 16.dp
)
modifier = Modifier
.fillMaxSize()
.background(drawerBackgroundColor)
) {
// Avatar
val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme)
// ═══════════════════════════════════════════════════════════
// 🎨 DRAWER HEADER - Avatar and status
// ═══════════════════════════════════════════════════════════
val headerColor = if (isDarkTheme) {
Color(0xFF2C5282)
} else {
Color(0xFF4A90D9)
}
Box(
modifier =
Modifier.size(64.dp)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
modifier = Modifier
.fillMaxWidth()
.background(color = headerColor)
.statusBarsPadding() // 🎨 Контент начинается после status bar
.padding(
top = 16.dp,
start = 20.dp,
end = 20.dp,
bottom = 20.dp
)
) {
Text(
text = getAvatarText(accountPublicKey),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
Column {
// Avatar with border
val avatarColors = getAvatarColor(accountPublicKey, isDarkTheme)
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.padding(3.dp)
.clip(CircleShape)
.background(avatarColors.backgroundColor),
contentAlignment = Alignment.Center
) {
Text(
text = getAvatarText(accountPublicKey),
fontSize = 26.sp,
fontWeight = FontWeight.Bold,
color = avatarColors.textColor
)
}
Spacer(modifier = Modifier.height(14.dp))
// Public key (username style) - clickable для копирования
val truncatedKey =
if (accountPublicKey.length > 16) {
"${accountPublicKey.take(8)}...${accountPublicKey.takeLast(6)}"
} else accountPublicKey
val context = androidx.compose.ui.platform.LocalContext.current
var showCopiedToast by remember { mutableStateOf(false) }
// Плавная замена текста
AnimatedContent(
targetState = showCopiedToast,
transitionSpec = {
fadeIn(animationSpec = tween(300)) togetherWith
fadeOut(animationSpec = tween(300))
},
label = "copiedAnimation"
) { isCopied ->
Text(
text = if (isCopied) "Copied!" else truncatedKey,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontStyle = if (isCopied) androidx.compose.ui.text.font.FontStyle.Italic else androidx.compose.ui.text.font.FontStyle.Normal,
modifier = Modifier.clickable {
if (!showCopiedToast) {
// Копируем публичный ключ
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
val clip = android.content.ClipData.newPlainText("Public Key", accountPublicKey)
clipboard.setPrimaryClip(clip)
showCopiedToast = true
}
}
)
}
// Автоматически возвращаем обратно через 1.5 секунды
if (showCopiedToast) {
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(1500)
showCopiedToast = false
}
}
Spacer(modifier = Modifier.height(6.dp))
// Connection status indicator
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showStatusDialog = true }
) {
val statusColor = when (protocolState) {
ProtocolState.AUTHENTICATED -> Color(0xFF4ADE80)
ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> Color(0xFFFBBF24)
else -> Color(0xFFF87171)
}
val statusText = when (protocolState) {
ProtocolState.AUTHENTICATED -> "Online"
ProtocolState.CONNECTING, ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> "Connecting..."
else -> "Offline"
}
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColor)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = statusText,
fontSize = 13.sp,
color = Color.White.copy(alpha = 0.85f)
)
}
}
}
// ═══════════════════════════════════════════════════════════
// 📱 MENU ITEMS
// ═══════════════════════════════════════════════════════════
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp)
) {
val menuIconColor = if (isDarkTheme) Color(0xFFB0B0B0) else Color(0xFF5C5C5C)
// 👤 Profile Section
DrawerMenuItemEnhanced(
icon = Icons.Outlined.Person,
text = "My Profile",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onProfileClick()
}
)
// 📖 Saved Messages
DrawerMenuItemEnhanced(
icon = Icons.Outlined.Bookmark,
text = "Saved Messages",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onSavedMessagesClick()
}
)
DrawerDivider(isDarkTheme)
// 👥 Contacts
DrawerMenuItemEnhanced(
icon = Icons.Outlined.Contacts,
text = "Contacts",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onContactsClick()
}
)
// 📞 Calls
DrawerMenuItemEnhanced(
icon = Icons.Outlined.Call,
text = "Calls",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onCallsClick()
}
)
// Invite Friends
DrawerMenuItemEnhanced(
icon = Icons.Outlined.PersonAdd,
text = "Invite Friends",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onInviteFriendsClick()
}
)
DrawerDivider(isDarkTheme)
// ⚙️ Settings
DrawerMenuItemEnhanced(
icon = Icons.Outlined.Settings,
text = "Settings",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
onSettingsClick()
}
)
// 🌓 Theme Toggle
DrawerMenuItemEnhanced(
icon = if (isDarkTheme) Icons.Outlined.LightMode else Icons.Outlined.DarkMode,
text = if (isDarkTheme) "Light Mode" else "Dark Mode",
iconColor = menuIconColor,
textColor = textColor,
onClick = { onToggleTheme() }
)
// ❓ Help
DrawerMenuItemEnhanced(
icon = Icons.Outlined.HelpOutline,
text = "Help & FAQ",
iconColor = menuIconColor,
textColor = textColor,
onClick = {
scope.launch { drawerState.close() }
// TODO: Add help screen navigation
}
)
}
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
)
}
Divider(color = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8))
// Menu Items - прозрачный фон без странных цветов
val drawerItemColors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = Color.Transparent,
unselectedIconColor = if (isDarkTheme) Color.White else Color.Black,
unselectedTextColor = textColor
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Person, contentDescription = null) },
label = { Text("My Profile") },
selected = false,
colors = drawerItemColors,
onClick = {
scope.launch { drawerState.close() }
onProfileClick()
}
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Settings, contentDescription = null) },
label = { Text("Settings") },
selected = false,
colors = drawerItemColors,
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,
colors = drawerItemColors,
onClick = {
// Don't close drawer when toggling theme
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,
colors = NavigationDrawerItemDefaults.colors(
unselectedContainerColor = Color.Transparent
),
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines.delay(150)
onLogout()
// ═══════════════════════════════════════════════════════════
// 🚪 FOOTER - Logout & Version
// ═══════════════════════════════════════════════════════════
Column(
modifier = Modifier.fillMaxWidth()
) {
Divider(
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8),
thickness = 0.5.dp
)
// Logout
DrawerMenuItemEnhanced(
icon = Icons.Outlined.Logout,
text = "Log Out",
iconColor = Color(0xFFFF4444),
textColor = Color(0xFFFF4444),
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines.delay(150)
onLogout()
}
}
}
)
)
Spacer(modifier = Modifier.height(24.dp))
// Version info
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterStart
) {
Text(
text = "Rosetta v1.0.0",
fontSize = 12.sp,
color = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
) {
@@ -1442,7 +1603,7 @@ fun RequestsScreen(
modifier = Modifier.fillMaxSize()
) {
items(requests, key = { it.opponentKey }) { request ->
DialogItem(
DialogItemContent(
dialog = request,
isDarkTheme = isDarkTheme,
isTyping = false,
@@ -1459,3 +1620,73 @@ fun RequestsScreen(
}
}
}
/**
* 🎨 Enhanced Drawer Menu Item - красивый пункт меню с hover эффектом
*/
@Composable
fun DrawerMenuItemEnhanced(
icon: androidx.compose.ui.graphics.vector.ImageVector,
text: String,
iconColor: Color,
textColor: Color,
badge: String? = null,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(20.dp))
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = textColor,
modifier = Modifier.weight(1f)
)
badge?.let {
Box(
modifier = Modifier
.background(
color = Color(0xFF4A90D9),
shape = RoundedCornerShape(10.dp)
)
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = it,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Color.White
)
}
}
}
}
/**
* 📏 Drawer Divider - разделитель между секциями
*/
@Composable
fun DrawerDivider(isDarkTheme: Boolean) {
Spacer(modifier = Modifier.height(8.dp))
Divider(
modifier = Modifier.padding(horizontal = 20.dp),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE),
thickness = 0.5.dp
)
Spacer(modifier = Modifier.height(8.dp))
}

View File

@@ -253,8 +253,30 @@ fun SearchScreen(
}
} else {
// Search Results
// Проверяем, не ищет ли пользователь сам себя (Saved Messages)
val isSavedMessagesSearch = searchQuery.trim().let { query ->
query.equals(currentUserPublicKey, ignoreCase = true) ||
query.equals(currentUserPublicKey.take(8), ignoreCase = true) ||
query.equals(currentUserPublicKey.takeLast(8), ignoreCase = true)
}
// Если ищем себя - показываем Saved Messages как первый результат
val resultsWithSavedMessages = if (isSavedMessagesSearch && searchResults.none { it.publicKey == currentUserPublicKey }) {
listOf(
SearchUser(
title = "Saved Messages",
username = "",
publicKey = currentUserPublicKey,
verified = 0,
online = 1
)
) + searchResults.filter { it.publicKey != currentUserPublicKey }
} else {
searchResults
}
SearchResultsList(
searchResults = searchResults,
searchResults = resultsWithSavedMessages,
isSearching = isSearching,
currentUserPublicKey = currentUserPublicKey,
isDarkTheme = isDarkTheme,
@@ -262,8 +284,10 @@ fun SearchScreen(
onUserClick = { user ->
// Мгновенно закрываем клавиатуру
hideKeyboardInstantly()
// Сохраняем пользователя в историю
RecentSearchesManager.addUser(user)
// Сохраняем пользователя в историю (кроме Saved Messages)
if (user.publicKey != currentUserPublicKey) {
RecentSearchesManager.addUser(user)
}
onUserSelect(user)
}
)

View File

@@ -6,33 +6,73 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* Telegram-inspired typography system
* Based on Telegram Android typography with system default font (Roboto)
*/
val Typography = Typography(
// Основной текст сообщений (как в Telegram: 16sp)
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 34.sp,
lineHeight = 22.4.sp, // 1.4x
letterSpacing = 0.sp
),
// Имя/заголовок в диалогах (17sp как в Telegram)
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 17.sp,
lineHeight = 23.8.sp, // 1.4x
letterSpacing = 0.sp
),
// Заголовок большой (20sp)
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
// Основной текст UI (16sp)
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
fontSize = 16.sp,
lineHeight = 22.4.sp, // 1.4x
letterSpacing = 0.sp
),
// Мелкий текст: время, статусы (13sp)
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.2.sp, // 1.4x
letterSpacing = 0.sp
),
// Лейбл для кнопок (14sp)
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp
),
// Средний лейбл (14sp)
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 19.6.sp, // 1.4x
letterSpacing = 0.sp
),
// Мелкий лейбл (13sp)
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 13.sp,
lineHeight = 18.2.sp, // 1.4x
letterSpacing = 0.sp
)
)

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Primary brand color - синий из Telegram -->
<color name="primary_blue">#3390EC</color>
<!-- Additional colors if needed -->
<color name="primary_blue_pressed">#2B7CD3</color>
<color name="primary_blue_disabled">#9BCDFF</color>
</resources>