diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 66c50d7..09f6860 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -24,7 +24,10 @@ enum class ProtocolState { * Protocol client for Rosetta Messenger * Handles WebSocket connection and packet exchange with server */ -class Protocol(private val serverAddress: String) { +class Protocol( + private val serverAddress: String, + private val logger: (String) -> Unit = {} +) { companion object { private const val TAG = "RosettaProtocol" private const val RECONNECT_INTERVAL = 10000L // 10 seconds @@ -32,6 +35,11 @@ class Protocol(private val serverAddress: String) { private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds } + private fun log(message: String) { + Log.d(TAG, message) + logger(message) + } + private val client = OkHttpClient.Builder() .readTimeout(0, TimeUnit.MILLISECONDS) .connectTimeout(10, TimeUnit.SECONDS) @@ -75,7 +83,7 @@ class Protocol(private val serverAddress: String) { // Register handshake response handler waitPacket(0x00) { packet -> if (packet is PacketHandshake) { - Log.d(TAG, "✅ Handshake response received, protocol version: ${packet.protocolVersion}") + log("✅ Handshake response received, protocol version: ${packet.protocolVersion}") handshakeJob?.cancel() handshakeComplete = true _state.value = ProtocolState.AUTHENTICATED @@ -89,7 +97,7 @@ class Protocol(private val serverAddress: String) { */ fun connect() { if (_state.value == ProtocolState.CONNECTING || _state.value == ProtocolState.CONNECTED) { - Log.d(TAG, "Already connecting or connected") + log("Already connecting or connected") return } @@ -97,7 +105,7 @@ class Protocol(private val serverAddress: String) { _state.value = ProtocolState.CONNECTING _lastError.value = null - Log.d(TAG, "🔌 Connecting to: $serverAddress") + log("🔌 Connecting to: $serverAddress") val request = Request.Builder() .url(serverAddress) @@ -105,7 +113,7 @@ class Protocol(private val serverAddress: String) { webSocket = client.newWebSocket(request, object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { - Log.d(TAG, "✅ WebSocket connected") + log("✅ WebSocket connected") reconnectAttempts = 0 _state.value = ProtocolState.CONNECTED @@ -122,20 +130,20 @@ class Protocol(private val serverAddress: String) { } override fun onMessage(webSocket: WebSocket, text: String) { - Log.d(TAG, "Received text message (unexpected): $text") + log("Received text message (unexpected): $text") } override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "WebSocket closing: $code - $reason") + log("WebSocket closing: $code - $reason") } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { - Log.d(TAG, "WebSocket closed: $code - $reason") + log("WebSocket closed: $code - $reason") handleDisconnect() } override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { - Log.e(TAG, "❌ WebSocket error: ${t.message}") + log("❌ WebSocket error: ${t.message}") _lastError.value = t.message handleDisconnect() } @@ -146,16 +154,16 @@ class Protocol(private val serverAddress: String) { * Start handshake with server */ fun startHandshake(publicKey: String, privateHash: String) { - Log.d(TAG, "🤝 Starting handshake...") - Log.d(TAG, " Public key: ${publicKey.take(20)}...") - Log.d(TAG, " Private hash: ${privateHash.take(20)}...") + log("🤝 Starting handshake...") + log(" Public key: ${publicKey.take(20)}...") + log(" Private hash: ${privateHash.take(20)}...") // Save credentials for reconnection lastPublicKey = publicKey lastPrivateHash = privateHash if (_state.value != ProtocolState.CONNECTED && _state.value != ProtocolState.AUTHENTICATED) { - Log.d(TAG, "Not connected, will handshake after connection") + log("Not connected, will handshake after connection") connect() return } @@ -175,7 +183,7 @@ class Protocol(private val serverAddress: String) { handshakeJob = scope.launch { delay(HANDSHAKE_TIMEOUT) if (!handshakeComplete) { - Log.e(TAG, "❌ Handshake timeout") + log("❌ Handshake timeout") _lastError.value = "Handshake timeout" disconnect() } @@ -188,7 +196,7 @@ class Protocol(private val serverAddress: String) { */ fun sendPacket(packet: Packet) { if (!handshakeComplete && packet !is PacketHandshake) { - Log.d(TAG, "📦 Queueing packet: ${packet.getPacketId()}") + log("📦 Queueing packet: ${packet.getPacketId()}") packetQueue.add(packet) return } @@ -199,13 +207,13 @@ class Protocol(private val serverAddress: String) { val stream = packet.send() val data = stream.getStream() - Log.d(TAG, "📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)") + log("📤 Sending packet: ${packet.getPacketId()} (${data.size} bytes)") webSocket?.send(ByteString.of(*data)) } private fun flushPacketQueue() { - Log.d(TAG, "📬 Flushing ${packetQueue.size} queued packets") + log("📬 Flushing ${packetQueue.size} queued packets") val packets = packetQueue.toList() packetQueue.clear() packets.forEach { sendPacketDirect(it) } @@ -216,11 +224,11 @@ class Protocol(private val serverAddress: String) { val stream = Stream(data) val packetId = stream.readInt16() - Log.d(TAG, "📥 Received packet: $packetId") + log("📥 Received packet: $packetId") val packetFactory = supportedPackets[packetId] if (packetFactory == null) { - Log.w(TAG, "Unknown packet ID: $packetId") + log("⚠️ Unknown packet ID: $packetId") return } @@ -232,11 +240,11 @@ class Protocol(private val serverAddress: String) { try { callback(packet) } catch (e: Exception) { - Log.e(TAG, "Error in packet handler: ${e.message}") + log("❌ Error in packet handler: ${e.message}") } } } catch (e: Exception) { - Log.e(TAG, "Error parsing packet: ${e.message}") + log("❌ Error parsing packet: ${e.message}") } } @@ -247,14 +255,14 @@ class Protocol(private val serverAddress: String) { if (!isManuallyClosed && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++ - Log.d(TAG, "🔄 Reconnecting in ${RECONNECT_INTERVAL}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)") + log("🔄 Reconnecting in ${RECONNECT_INTERVAL}ms (attempt $reconnectAttempts/$MAX_RECONNECT_ATTEMPTS)") scope.launch { delay(RECONNECT_INTERVAL) connect() } } else if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { - Log.e(TAG, "❌ Max reconnect attempts reached") + log("❌ Max reconnect attempts reached") _lastError.value = "Unable to connect to server" } } @@ -277,7 +285,7 @@ class Protocol(private val serverAddress: String) { * Disconnect from server */ fun disconnect() { - Log.d(TAG, "Disconnecting...") + log("Disconnecting...") isManuallyClosed = true handshakeJob?.cancel() webSocket?.close(1000, "User disconnected") diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 6b65044..72d9f69 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -1,7 +1,11 @@ package com.rosetta.messenger.network import android.util.Log +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.text.SimpleDateFormat +import java.util.* /** * Singleton manager for Protocol instance @@ -15,13 +19,31 @@ object ProtocolManager { private var protocol: Protocol? = null + // Debug logs for dev console + private val _debugLogs = MutableStateFlow>(emptyList()) + val debugLogs: StateFlow> = _debugLogs.asStateFlow() + + private val dateFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) + + fun addLog(message: String) { + val timestamp = dateFormat.format(Date()) + val logLine = "[$timestamp] $message" + Log.d(TAG, logLine) + _debugLogs.value = (_debugLogs.value + logLine).takeLast(100) + } + + fun clearLogs() { + _debugLogs.value = emptyList() + } + /** * Get or create Protocol instance */ fun getProtocol(): Protocol { if (protocol == null) { - Log.d(TAG, "Creating new Protocol instance") - protocol = Protocol(SERVER_ADDRESS) + addLog("Creating new Protocol instance") + addLog("Server: $SERVER_ADDRESS") + protocol = Protocol(SERVER_ADDRESS) { msg -> addLog(msg) } } return protocol!! } @@ -42,6 +64,7 @@ object ProtocolManager { * Connect to server */ fun connect() { + addLog("Connect requested") getProtocol().connect() } @@ -49,7 +72,9 @@ object ProtocolManager { * Authenticate with server */ fun authenticate(publicKey: String, privateHash: String) { - Log.d(TAG, "Authenticating...") + addLog("Authenticate called") + addLog("PublicKey: ${publicKey.take(30)}...") + addLog("PrivateHash: ${privateHash.take(20)}...") getProtocol().startHandshake(publicKey, privateHash) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index 38dfbae..5e83be5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -63,8 +63,8 @@ fun SetPasswordScreen( } val passwordsMatch = password == confirmPassword && password.isNotEmpty() - val passwordStrong = password.length >= 6 - val canContinue = passwordsMatch && passwordStrong && !isCreating + val isPasswordWeak = password.isNotEmpty() && password.length < 6 + val canContinue = passwordsMatch && !isCreating Box( modifier = Modifier @@ -223,32 +223,60 @@ fun SetPasswordScreen( animationSpec = tween(400, delayMillis = 350) ) ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - val strength = when { - password.length < 6 -> "Weak" - password.length < 10 -> "Medium" - else -> "Strong" + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + val strength = when { + password.length < 6 -> "Weak" + password.length < 10 -> "Medium" + else -> "Strong" + } + val strengthColor = when { + password.length < 6 -> Color(0xFFE53935) + password.length < 10 -> Color(0xFFFFA726) + else -> Color(0xFF4CAF50) + } + Icon( + imageVector = Icons.Default.Shield, + contentDescription = null, + tint = strengthColor, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Password strength: $strength", + fontSize = 12.sp, + color = strengthColor + ) } - val strengthColor = when { - password.length < 6 -> Color(0xFFE53935) - password.length < 10 -> Color(0xFFFFA726) - else -> Color(0xFF4CAF50) + // Warning for weak passwords + if (isPasswordWeak) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFFE53935).copy(alpha = 0.1f)) + .padding(8.dp), + verticalAlignment = Alignment.Top + ) { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = Color(0xFFE53935), + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Your password is too weak. Consider using at least 6 characters for better security.", + fontSize = 11.sp, + color = Color(0xFFE53935), + lineHeight = 14.sp + ) + } } - Icon( - imageVector = Icons.Default.Shield, - contentDescription = null, - tint = strengthColor, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = "Password strength: $strength", - fontSize = 12.sp, - color = strengthColor - ) } } } @@ -394,10 +422,6 @@ fun SetPasswordScreen( ) { Button( onClick = { - if (!passwordStrong) { - error = "Password must be at least 6 characters" - return@Button - } if (!passwordsMatch) { error = "Passwords don't match" return@Button diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt index b3f239d..553af4d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/WelcomeScreen.kt @@ -117,8 +117,8 @@ fun WelcomeScreen( ) ) { Text( - text = "Rosetta uses cryptographic keys\nto secure your messages.\n\nNo account registration,\nno phone number required.", - fontSize = 15.sp, + text = "Secure messaging with\ncryptographic keys", + fontSize = 16.sp, color = secondaryTextColor, textAlign = TextAlign.Center, lineHeight = 24.sp, @@ -126,14 +126,46 @@ fun WelcomeScreen( ) } - Spacer(modifier = Modifier.weight(0.3f)) + Spacer(modifier = Modifier.height(24.dp)) + + // Features list with icons - placed above buttons + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(600, delayMillis = 400)) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + CompactFeatureItem( + icon = Icons.Default.Security, + text = "Encrypted", + isDarkTheme = isDarkTheme, + textColor = textColor + ) + CompactFeatureItem( + icon = Icons.Default.NoAccounts, + text = "No Phone", + isDarkTheme = isDarkTheme, + textColor = textColor + ) + CompactFeatureItem( + icon = Icons.Default.Key, + text = "Your Keys", + isDarkTheme = isDarkTheme, + textColor = textColor + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) // Create Seed Button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically( + enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically( initialOffsetY = { 100 }, - animationSpec = tween(600, delayMillis = 400) + animationSpec = tween(600, delayMillis = 500) ) ) { Button( @@ -145,14 +177,18 @@ fun WelcomeScreen( containerColor = PrimaryBlue, contentColor = Color.White ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(16.dp), + elevation = ButtonDefaults.buttonElevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp + ) ) { Icon( imageVector = Icons.Default.Key, contentDescription = null, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(22.dp) ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(12.dp)) Text( text = "Generate New Seed Phrase", fontSize = 16.sp, @@ -161,70 +197,35 @@ fun WelcomeScreen( } } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) // Import Seed Button AnimatedVisibility( visible = visible, - enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically( + enter = fadeIn(tween(600, delayMillis = 600)) + slideInVertically( initialOffsetY = { 100 }, - animationSpec = tween(600, delayMillis = 500) + animationSpec = tween(600, delayMillis = 600) ) ) { - OutlinedButton( + TextButton( onClick = onImportSeed, modifier = Modifier .fillMaxWidth() .height(56.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = PrimaryBlue - ), - border = ButtonDefaults.outlinedButtonBorder.copy( - brush = Brush.horizontalGradient(listOf(PrimaryBlue, PrimaryBlue)) - ), - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(16.dp) ) { Icon( imageVector = Icons.Default.Download, contentDescription = null, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), + tint = PrimaryBlue ) Spacer(modifier = Modifier.width(8.dp)) Text( text = "I Already Have a Seed Phrase", - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Info text - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(600, delayMillis = 600)) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isDarkTheme) AuthSurface else AuthSurfaceLight) - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Your seed phrase is the master key to your account. Keep it safe and never share it.", - fontSize = 14.sp, - color = secondaryTextColor, - lineHeight = 18.sp + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = PrimaryBlue ) } } @@ -233,3 +234,84 @@ fun WelcomeScreen( } } } + +@Composable +private fun CompactFeatureItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isDarkTheme: Boolean, + textColor: Color +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(24.dp) + ) + } + Text( + text = text, + fontSize = 13.sp, + color = textColor.copy(alpha = 0.8f), + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + } +} + +@Composable +private fun FeatureItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String, + isDarkTheme: Boolean, + textColor: Color +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(PrimaryBlue.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = PrimaryBlue, + modifier = Modifier.size(20.dp) + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + fontSize = 15.sp, + color = textColor, + fontWeight = FontWeight.Medium + ) + } +} + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = text, + fontSize = 15.sp, + color = textColor, + fontWeight = FontWeight.Medium + ) + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 6b27313..3199c26 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 @@ -124,6 +124,12 @@ fun ChatsListScreen( // Protocol connection state val protocolState by ProtocolManager.state.collectAsState() + val debugLogs by ProtocolManager.debugLogs.collectAsState() + + // Dev console state + var showDevConsole by remember { mutableStateOf(false) } + var titleClickCount by remember { mutableStateOf(0) } + var lastClickTime by remember { mutableStateOf(0L) } var visible by remember { mutableStateOf(false) } @@ -131,6 +137,95 @@ fun ChatsListScreen( visible = true } + // Dev console dialog + 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) + ) + } + } + } + } + } + }, + confirmButton = { + Row { + TextButton(onClick = { ProtocolManager.clearLogs() }) { + Text("Clear") + } + TextButton(onClick = { + ProtocolManager.connect() + }) { + Text("Reconnect") + } + TextButton(onClick = { showDevConsole = false }) { + Text("Close") + } + } + }, + containerColor = if (isDarkTheme) Color(0xFF212121) else Color.White + ) + } + // Drawer menu items val menuItems = listOf( DrawerMenuItem( @@ -211,17 +306,73 @@ fun ChatsListScreen( } // Menu items - menuItems.forEach { item -> - DrawerItem( - icon = item.icon, - title = item.title, - onClick = { + 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) + ) + } + } + } + + // 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() } - item.onClick() - }, - isDarkTheme = isDarkTheme + 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)) } } ) { @@ -247,9 +398,22 @@ fun ChatsListScreen( } }, title = { - // Stories / Title area + // Stories / Title area - Triple click to open dev console Row( - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { + val currentTime = System.currentTimeMillis() + if (currentTime - lastClickTime < 500) { + titleClickCount++ + if (titleClickCount >= 3) { + showDevConsole = true + titleClickCount = 0 + } + } else { + titleClickCount = 1 + } + lastClickTime = currentTime + } ) { // User story avatar placeholder Box( diff --git a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt index f24afad..2eae784 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/onboarding/OnboardingScreen.kt @@ -98,13 +98,11 @@ fun OnboardingScreen( val elapsed = System.currentTimeMillis() - startTime transitionProgress = (elapsed / duration).coerceAtMost(1f) - // Update status bar when wave reaches top (around 15% progress) - if (transitionProgress >= 0.15f && !shouldUpdateStatusBar) { - shouldUpdateStatusBar = true - } - delay(16) // ~60fps } + // Update status bar icons after animation is completely finished + shouldUpdateStatusBar = true + delay(50) // Small delay to ensure UI updates isTransitioning = false transitionProgress = 0f shouldUpdateStatusBar = false @@ -112,9 +110,34 @@ fun OnboardingScreen( } } - // Update status bar and navigation bar icons when wave reaches the top + // Animate navigation bar color starting at 80% of wave animation val view = LocalView.current - LaunchedEffect(shouldUpdateStatusBar, isDarkTheme) { + LaunchedEffect(isTransitioning, transitionProgress) { + if (isTransitioning && transitionProgress >= 0.8f && !view.isInEditMode) { + val window = (view.context as android.app.Activity).window + // Map 0.8-1.0 to 0-1 for smooth interpolation + val navProgress = ((transitionProgress - 0.8f) / 0.2f).coerceIn(0f, 1f) + + val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF + val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF + + val r1 = (oldColor shr 16 and 0xFF) + val g1 = (oldColor shr 8 and 0xFF) + val b1 = (oldColor and 0xFF) + val r2 = (newColor shr 16 and 0xFF) + val g2 = (newColor shr 8 and 0xFF) + val b2 = (newColor and 0xFF) + + val r = (r1 + (r2 - r1) * navProgress).toInt() + val g = (g1 + (g2 - g1) * navProgress).toInt() + val b = (b1 + (b2 - b1) * navProgress).toInt() + + window.navigationBarColor = (0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()).toInt() + } + } + + // Update status bar icons when animation finishes + LaunchedEffect(shouldUpdateStatusBar) { if (shouldUpdateStatusBar && !view.isInEditMode) { val window = (view.context as android.app.Activity).window val insetsController = WindowCompat.getInsetsController(window, view) @@ -124,32 +147,11 @@ fun OnboardingScreen( } } - // Animate navigation bar color with theme transition - LaunchedEffect(isTransitioning, transitionProgress, isDarkTheme) { + // Set initial navigation bar color only on first launch + LaunchedEffect(Unit) { if (!view.isInEditMode) { val window = (view.context as android.app.Activity).window - if (isTransitioning) { - // Interpolate color during transition - val oldColor = if (previousTheme) 0xFF1E1E1E else 0xFFFFFFFF - val newColor = if (targetTheme) 0xFF1E1E1E else 0xFFFFFFFF - - val r1 = (oldColor shr 16 and 0xFF) - val g1 = (oldColor shr 8 and 0xFF) - val b1 = (oldColor and 0xFF) - - val r2 = (newColor shr 16 and 0xFF) - val g2 = (newColor shr 8 and 0xFF) - val b2 = (newColor and 0xFF) - - val r = (r1 + (r2 - r1) * transitionProgress).toInt() - val g = (g1 + (g2 - g1) * transitionProgress).toInt() - val b = (b1 + (b2 - b1) * transitionProgress).toInt() - - window.navigationBarColor = (0xFF000000 or (r.toLong() shl 16) or (g.toLong() shl 8) or b.toLong()).toInt() - } else { - // Set final color when not transitioning - window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() - } + window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() } } @@ -387,42 +389,40 @@ fun AnimatedRosettaLogo( ) Box( - modifier = modifier - .scale(scale) - .graphicsLayer { this.alpha = alpha }, + modifier = modifier, contentAlignment = Alignment.Center ) { // Pre-render all animations to avoid lag Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - // Rosetta icon (page 0) with pulse animation + // Rosetta icon (page 0) with pulse animation like splash screen if (currentPage == 0) { val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat( initialValue = 1f, - targetValue = 1.08f, + targetValue = 1.1f, animationSpec = infiniteRepeatable( - animation = tween(1000, easing = FastOutSlowInEasing), + animation = tween(800, easing = FastOutSlowInEasing), repeatMode = RepeatMode.Reverse ), label = "pulseScale" ) - // Glow effect behind logo - separate Box without clipping + // Glow effect behind logo - same style as splash screen Box( modifier = Modifier - .size(200.dp) + .size(180.dp) .scale(pulseScale) .background( - color = Color(0xFF54A9EB).copy(alpha = 0.15f), + color = Color(0xFF54A9EB).copy(alpha = 0.2f), shape = CircleShape ) ) + // Main logo - circular like splash screen Image( painter = painterResource(id = R.drawable.rosetta_icon), contentDescription = "Rosetta Logo", modifier = Modifier - .size(180.dp) - .scale(pulseScale) + .size(150.dp) .clip(CircleShape) ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt index a859222..8e6a3bf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/theme/Theme.kt @@ -68,9 +68,9 @@ fun RosettaAndroidTheme( val window = (view.context as android.app.Activity).window // Make status bar transparent for wave animation overlay window.statusBarColor = AndroidColor.TRANSPARENT - window.navigationBarColor = if (darkTheme) 0xFF1B1B1B.toInt() else 0xFFFFFFFF.toInt() + // Navigation bar color is managed by OnboardingScreen for smooth transition + // Don't change it here to avoid instant color change WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme - WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme } }