Initial commit: rosetta-android-prime

This commit is contained in:
k1ngsterr1
2026-01-08 19:06:37 +05:00
commit 42ddfe5b18
54 changed files with 68604 additions and 0 deletions

View File

@@ -0,0 +1,156 @@
package com.rosetta.messenger
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.ui.auth.AuthFlow
import com.rosetta.messenger.ui.onboarding.OnboardingScreen
import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
preferencesManager = PreferencesManager(this)
accountManager = AccountManager(this)
setContent {
val scope = rememberCoroutineScope()
val isDarkTheme by preferencesManager.isDarkTheme.collectAsState(initial = true)
val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null)
var showSplash by remember { mutableStateOf(true) }
var showOnboarding by remember { mutableStateOf(true) }
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
var currentAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
// Check for existing accounts
LaunchedEffect(Unit) {
val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty()
}
// Wait for initial load
if (hasExistingAccount == null) {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
)
return@setContent
}
RosettaAndroidTheme(
darkTheme = isDarkTheme,
animated = true
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White
) {
AnimatedContent(
targetState = when {
showSplash -> "splash"
showOnboarding && hasExistingAccount == false -> "onboarding"
isLoggedIn != true && hasExistingAccount == false -> "auth_new"
isLoggedIn != true && hasExistingAccount == true -> "auth_unlock"
else -> "main"
},
transitionSpec = {
fadeIn(animationSpec = tween(600)) togetherWith
fadeOut(animationSpec = tween(600))
},
label = "screenTransition"
) { screen ->
when (screen) {
"splash" -> {
SplashScreen(
isDarkTheme = isDarkTheme,
onSplashComplete = { showSplash = false }
)
}
"onboarding" -> {
OnboardingScreen(
isDarkTheme = isDarkTheme,
onThemeToggle = {
scope.launch {
preferencesManager.setDarkTheme(!isDarkTheme)
}
},
onStartMessaging = {
showOnboarding = false
}
)
}
"auth_new", "auth_new", "auth_unlock" -> {
AuthFlow(
isDarkTheme = isDarkTheme,
hasExistingAccount = screen == "auth_unlock",
onAuthComplete = { account ->
currentAccount = account
hasExistingAccount = true
}
)
}
"main" -> {
MainScreen(
account = currentAccount,
onLogout = {
scope.launch {
accountManager.logout()
currentAccount = null
}
}
)
}
}
}
}
}
}
}
}
@Composable
fun MainScreen(
account: DecryptedAccount? = null,
onLogout: () -> Unit = {}
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Welcome to Rosetta! 🚀\n\nYou're logged in!",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground
)
}
}

View File

@@ -0,0 +1,201 @@
package com.rosetta.messenger.crypto
import org.bitcoinj.crypto.MnemonicCode
import org.bitcoinj.crypto.MnemonicException
import org.bouncycastle.jce.ECNamedCurveTable
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.jce.spec.ECPrivateKeySpec
import org.bouncycastle.jce.spec.ECPublicKeySpec
import java.math.BigInteger
import java.security.*
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
import android.util.Base64
import java.security.spec.PKCS8EncodedKeySpec
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.Inflater
/**
* Cryptography module for Rosetta Messenger
* Implements BIP39 seed phrase generation and secp256k1 key derivation
*/
object CryptoManager {
private const val PBKDF2_ITERATIONS = 1000
private const val KEY_SIZE = 256
private const val SALT = "rosetta"
init {
// Add BouncyCastle provider for secp256k1 support
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(BouncyCastleProvider())
}
}
/**
* Generate a new 12-word BIP39 seed phrase
*/
fun generateSeedPhrase(): List<String> {
val secureRandom = SecureRandom()
val entropy = ByteArray(16) // 128 bits = 12 words
secureRandom.nextBytes(entropy)
val mnemonicCode = MnemonicCode.INSTANCE
return mnemonicCode.toMnemonic(entropy)
}
/**
* Validate a seed phrase
*/
fun validateSeedPhrase(words: List<String>): Boolean {
return try {
val mnemonicCode = MnemonicCode.INSTANCE
mnemonicCode.check(words)
true
} catch (e: MnemonicException) {
false
}
}
/**
* Convert seed phrase to private key (64 bytes hex string)
*/
fun seedPhraseToPrivateKey(seedPhrase: List<String>): String {
val mnemonicCode = MnemonicCode.INSTANCE
val seed = MnemonicCode.toSeed(seedPhrase, "")
// Convert to hex string (128 characters for 64 bytes)
return seed.joinToString("") { "%02x".format(it) }
}
/**
* Generate key pair from private key using secp256k1 curve
*/
fun generateKeyPairFromSeed(privateKeyHex: String): KeyPairData {
val ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1")
// Use first 32 bytes of private key for secp256k1
val privateKeyBytes = privateKeyHex.take(64).chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
val privateKeyBigInt = BigInteger(1, privateKeyBytes)
// Generate public key from private key
val publicKeyPoint = ecSpec.g.multiply(privateKeyBigInt)
val publicKeyHex = publicKeyPoint.getEncoded(false)
.joinToString("") { "%02x".format(it) }
return KeyPairData(
privateKey = privateKeyHex.take(64),
publicKey = publicKeyHex
)
}
/**
* Generate private key hash for protocol (SHA256(privateKey + "rosetta"))
*/
fun generatePrivateKeyHash(privateKey: String): String {
val data = (privateKey + SALT).toByteArray()
val digest = MessageDigest.getInstance("SHA-256")
val hash = digest.digest(data)
return hash.joinToString("") { "%02x".format(it) }
}
/**
* Encrypt data with password using PBKDF2 + AES
*/
fun encryptWithPassword(password: String, data: String): String {
// Compress data
val compressed = compress(data.toByteArray())
// Derive key using PBKDF2
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(), PBKDF2_ITERATIONS, KEY_SIZE)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Generate random IV
val iv = ByteArray(16)
SecureRandom().nextBytes(iv)
val ivSpec = IvParameterSpec(iv)
// Encrypt with AES
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec)
val encrypted = cipher.doFinal(compressed)
// Return iv:ciphertext in Base64
val ivBase64 = Base64.encodeToString(iv, Base64.NO_WRAP)
val ctBase64 = Base64.encodeToString(encrypted, Base64.NO_WRAP)
return "$ivBase64:$ctBase64"
}
/**
* Decrypt data with password
*/
fun decryptWithPassword(password: String, encryptedData: String): String? {
return try {
val parts = encryptedData.split(":")
if (parts.size != 2) return null
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP)
// Derive key using PBKDF2
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
val spec = PBEKeySpec(password.toCharArray(), SALT.toByteArray(), PBKDF2_ITERATIONS, KEY_SIZE)
val secretKey = factory.generateSecret(spec)
val key = SecretKeySpec(secretKey.encoded, "AES")
// Decrypt
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, key, IvParameterSpec(iv))
val decrypted = cipher.doFinal(ciphertext)
// Decompress
String(decompress(decrypted))
} catch (e: Exception) {
null
}
}
private fun compress(data: ByteArray): ByteArray {
val deflater = Deflater()
deflater.setInput(data)
deflater.finish()
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!deflater.finished()) {
val count = deflater.deflate(buffer)
outputStream.write(buffer, 0, count)
}
outputStream.close()
return outputStream.toByteArray()
}
private fun decompress(data: ByteArray): ByteArray {
val inflater = Inflater()
inflater.setInput(data)
val outputStream = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (!inflater.finished()) {
val count = inflater.inflate(buffer)
outputStream.write(buffer, 0, count)
}
outputStream.close()
return outputStream.toByteArray()
}
}
data class KeyPairData(
val privateKey: String,
val publicKey: String
)

View File

@@ -0,0 +1,118 @@
package com.rosetta.messenger.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.accountDataStore: DataStore<Preferences> by preferencesDataStore(name = "account_store")
/**
* Manages encrypted account storage using DataStore
*/
class AccountManager(private val context: Context) {
companion object {
private val CURRENT_PUBLIC_KEY = stringPreferencesKey("current_public_key")
private val ACCOUNTS_JSON = stringPreferencesKey("accounts_json")
private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
}
val currentPublicKey: Flow<String?> = context.accountDataStore.data.map { preferences ->
preferences[CURRENT_PUBLIC_KEY]
}
val isLoggedIn: Flow<Boolean> = context.accountDataStore.data.map { preferences ->
preferences[IS_LOGGED_IN] ?: false
}
val accountsJson: Flow<String?> = context.accountDataStore.data.map { preferences ->
preferences[ACCOUNTS_JSON]
}
suspend fun saveAccount(account: EncryptedAccount) {
context.accountDataStore.edit { preferences ->
val existingJson = preferences[ACCOUNTS_JSON]
val accounts = if (existingJson != null) {
parseAccounts(existingJson).toMutableList()
} else {
mutableListOf()
}
// Remove existing account with same public key
accounts.removeAll { it.publicKey == account.publicKey }
accounts.add(account)
preferences[ACCOUNTS_JSON] = serializeAccounts(accounts)
}
}
suspend fun getAccount(publicKey: String): EncryptedAccount? {
val preferences = context.accountDataStore.data.first()
val json = preferences[ACCOUNTS_JSON]
return if (json != null) parseAccounts(json).find { it.publicKey == publicKey } else null
}
suspend fun getAllAccounts(): List<EncryptedAccount> {
val preferences = context.accountDataStore.data.first()
val json = preferences[ACCOUNTS_JSON]
return if (json != null) parseAccounts(json) else emptyList()
}
suspend fun setCurrentAccount(publicKey: String) {
context.accountDataStore.edit { preferences ->
preferences[CURRENT_PUBLIC_KEY] = publicKey
preferences[IS_LOGGED_IN] = true
}
}
suspend fun logout() {
context.accountDataStore.edit { preferences ->
preferences[IS_LOGGED_IN] = false
}
}
suspend fun clearAll() {
context.accountDataStore.edit { it.clear() }
}
private fun serializeAccounts(accounts: List<EncryptedAccount>): String {
return accounts.joinToString("|||") { account ->
"${account.publicKey}::${account.encryptedPrivateKey}::${account.encryptedSeedPhrase}::${account.name}"
}
}
private fun parseAccounts(json: String): List<EncryptedAccount> {
if (json.isBlank()) return emptyList()
return json.split("|||").mapNotNull { accountStr ->
val parts = accountStr.split("::")
if (parts.size >= 4) {
EncryptedAccount(
publicKey = parts[0],
encryptedPrivateKey = parts[1],
encryptedSeedPhrase = parts[2],
name = parts[3]
)
} else null
}
}
}
data class EncryptedAccount(
val publicKey: String,
val encryptedPrivateKey: String,
val encryptedSeedPhrase: String,
val name: String = "Account"
)
data class DecryptedAccount(
val publicKey: String,
val privateKey: String,
val seedPhrase: List<String>,
val privateKeyHash: String,
val name: String = "Account"
)

View File

@@ -0,0 +1,42 @@
package com.rosetta.messenger.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "rosetta_preferences")
class PreferencesManager(private val context: Context) {
companion object {
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
}
val hasSeenOnboarding: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[HAS_SEEN_ONBOARDING] ?: false
}
val isDarkTheme: Flow<Boolean> = context.dataStore.data
.map { preferences ->
preferences[IS_DARK_THEME] ?: true // Default to dark theme like Telegram
}
suspend fun setHasSeenOnboarding(value: Boolean) {
context.dataStore.edit { preferences ->
preferences[HAS_SEEN_ONBOARDING] = value
}
}
suspend fun setDarkTheme(value: Boolean) {
context.dataStore.edit { preferences ->
preferences[IS_DARK_THEME] = value
}
}
}

View File

@@ -0,0 +1,93 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.runtime.*
import com.rosetta.messenger.data.DecryptedAccount
enum class AuthScreen {
WELCOME,
SEED_PHRASE,
CONFIRM_SEED,
SET_PASSWORD,
IMPORT_SEED,
UNLOCK
}
@Composable
fun AuthFlow(
isDarkTheme: Boolean,
hasExistingAccount: Boolean,
onAuthComplete: (DecryptedAccount?) -> Unit
) {
var currentScreen by remember {
mutableStateOf(if (hasExistingAccount) AuthScreen.UNLOCK else AuthScreen.WELCOME)
}
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
AnimatedContent(
targetState = currentScreen,
transitionSpec = {
fadeIn(animationSpec = tween(200)) togetherWith
fadeOut(animationSpec = tween(200))
},
label = "authScreenTransition"
) { screen ->
when (screen) {
AuthScreen.WELCOME -> {
WelcomeScreen(
isDarkTheme = isDarkTheme,
onCreateSeed = { currentScreen = AuthScreen.SEED_PHRASE },
onImportSeed = { currentScreen = AuthScreen.IMPORT_SEED }
)
}
AuthScreen.SEED_PHRASE -> {
SeedPhraseScreen(
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.WELCOME },
onConfirm = { words ->
seedPhrase = words
currentScreen = AuthScreen.CONFIRM_SEED
}
)
}
AuthScreen.CONFIRM_SEED -> {
ConfirmSeedPhraseScreen(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.SEED_PHRASE },
onConfirmed = { currentScreen = AuthScreen.SET_PASSWORD }
)
}
AuthScreen.SET_PASSWORD -> {
SetPasswordScreen(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.CONFIRM_SEED },
onAccountCreated = { onAuthComplete(null) }
)
}
AuthScreen.IMPORT_SEED -> {
ImportSeedPhraseScreen(
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.WELCOME },
onSeedPhraseImported = { words ->
seedPhrase = words
currentScreen = AuthScreen.SET_PASSWORD
}
)
}
AuthScreen.UNLOCK -> {
UnlockScreen(
isDarkTheme = isDarkTheme,
onUnlocked = { account -> onAuthComplete(account) }
)
}
}
}
}

View File

@@ -0,0 +1,247 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@Composable
fun ConfirmSeedPhraseScreen(
seedPhrase: List<String>,
isDarkTheme: Boolean,
onBack: () -> Unit,
onConfirmed: () -> Unit
) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec)
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec)
val cardColor by animateColorAsState(if (isDarkTheme) AuthSurface else AuthSurfaceLight, animationSpec = themeAnimSpec)
// Select 4 random words to confirm
val wordsToConfirm = remember {
listOf(1, 4, 8, 11).map { index -> index to seedPhrase[index] }
}
var userInputs by remember { mutableStateOf(List(4) { "" }) }
var showError by remember { mutableStateOf(false) }
val allCorrect = wordsToConfirm.mapIndexed { i, (_, word) ->
userInputs[i].trim().lowercase() == word.lowercase()
}.all { it }
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top Bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = textColor
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Confirm Backup",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp))
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
// Info Card
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Enter the following words from your seed phrase to confirm you've backed it up correctly.",
fontSize = 14.sp,
color = secondaryTextColor,
lineHeight = 18.sp
)
}
Spacer(modifier = Modifier.height(32.dp))
// Word inputs
wordsToConfirm.forEachIndexed { index, (wordIndex, _) ->
val isCorrect = userInputs[index].trim().lowercase() ==
wordsToConfirm[index].second.lowercase()
val hasInput = userInputs[index].isNotBlank()
WordInputField(
wordNumber = wordIndex + 1,
value = userInputs[index],
onValueChange = {
userInputs = userInputs.toMutableList().apply {
this[index] = it
}
showError = false
},
isCorrect = if (hasInput) isCorrect else null,
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(16.dp))
}
// Error message
AnimatedVisibility(
visible = showError,
enter = fadeIn() + slideInVertically { -10 },
exit = fadeOut()
) {
Text(
text = "Some words don't match. Please check and try again.",
fontSize = 14.sp,
color = Color(0xFFE53935),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.weight(1f))
// Continue Button
Button(
onClick = {
if (allCorrect) {
onConfirmed()
} else {
showError = true
}
},
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (allCorrect) PrimaryBlue else PrimaryBlue.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Confirm",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WordInputField(
wordNumber: Int,
value: String,
onValueChange: (String) -> Unit,
isCorrect: Boolean?,
isDarkTheme: Boolean
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val labelColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val borderColor = when (isCorrect) {
true -> Color(0xFF4CAF50)
false -> Color(0xFFE53935)
null -> if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0)
}
val trailingIcon: @Composable (() -> Unit)? = when (isCorrect) {
true -> {
{ Icon(Icons.Default.Check, null, tint = Color(0xFF4CAF50)) }
}
false -> {
{ Icon(Icons.Default.Close, null, tint = Color(0xFFE53935)) }
}
null -> null
}
OutlinedTextField(
value = value,
onValueChange = { onValueChange(it.lowercase().trim()) },
label = { Text("Word #$wordNumber") },
placeholder = { Text("Enter word $wordNumber") },
singleLine = true,
trailingIcon = trailingIcon,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = borderColor,
focusedLabelColor = PrimaryBlue,
unfocusedLabelColor = labelColor,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next
)
)
}

View File

@@ -0,0 +1,329 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImportSeedPhraseScreen(
isDarkTheme: Boolean,
onBack: () -> Unit,
onSeedPhraseImported: (List<String>) -> Unit
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
var words by remember { mutableStateOf(List(12) { "" }) }
var error by remember { mutableStateOf<String?>(null) }
var pastedText by remember { mutableStateOf("") }
var showPasteDialog by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
visible = true
}
val allWordsFilled = words.all { it.isNotBlank() }
// Parse pasted text
LaunchedEffect(pastedText) {
if (pastedText.isNotBlank()) {
val parsed = pastedText.trim().lowercase().split("\\s+".toRegex())
if (parsed.size == 12) {
words = parsed
showPasteDialog = false
error = null
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.statusBarsPadding()
) {
Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
}
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, easing = FastOutSlowInEasing))
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Import Account",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Enter your 12-word recovery phrase",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
// Paste button
Button(
onClick = { showPasteDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
) {
Icon(
Icons.Default.ContentPaste,
contentDescription = null,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(10.dp))
Text(
"Paste All 12 Words",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
Spacer(modifier = Modifier.height(24.dp))
// Clean grid
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardBackground)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
for (row in 0..3) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
for (col in 0..2) {
val index = row * 3 + col
WordInputItem(
number = index + 1,
value = words[index],
onValueChange = { newValue ->
words = words.toMutableList().apply {
this[index] = newValue.lowercase().trim()
}
error = null
},
isDarkTheme = isDarkTheme,
modifier = Modifier.weight(1f)
)
}
}
}
}
// Error
if (error != null) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = error ?: "",
fontSize = 14.sp,
color = Color(0xFFE53935),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.weight(1f))
// Import button
Button(
onClick = {
val seedPhrase = words.map { it.trim() }
if (seedPhrase.any { it.isBlank() }) {
error = "Please fill in all words"
return@Button
}
if (!CryptoManager.validateSeedPhrase(seedPhrase)) {
error = "Invalid recovery phrase"
return@Button
}
onSeedPhraseImported(seedPhrase)
},
enabled = allWordsFilled,
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
),
shape = RoundedCornerShape(12.dp)
) {
Text("Continue", fontSize = 17.sp, fontWeight = FontWeight.Medium)
}
Spacer(modifier = Modifier.height(40.dp))
}
}
}
// Paste dialog
if (showPasteDialog) {
AlertDialog(
onDismissRequest = { showPasteDialog = false },
title = {
Text("Paste Recovery Phrase", fontWeight = FontWeight.Bold)
},
text = {
OutlinedTextField(
value = pastedText,
onValueChange = { pastedText = it },
placeholder = { Text("Paste your 12 words here") },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
cursorColor = PrimaryBlue
),
shape = RoundedCornerShape(12.dp)
)
},
confirmButton = {
TextButton(
onClick = {
val parsed = pastedText.trim().lowercase().split("\\s+".toRegex())
if (parsed.size == 12) {
words = parsed
showPasteDialog = false
}
}
) {
Text("Import", color = PrimaryBlue, fontWeight = FontWeight.Medium)
}
},
dismissButton = {
TextButton(onClick = { showPasteDialog = false }) {
Text("Cancel")
}
},
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color.White,
shape = RoundedCornerShape(20.dp)
)
}
}
}
@Composable
private fun WordInputItem(
number: Int,
value: String,
onValueChange: (String) -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val itemBg = if (isDarkTheme) Color(0xFF1E1E1E) else Color.White
val numberColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA)
val textColor = if (isDarkTheme) Color.White else Color.Black
val hintColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA)
var isFocused by remember { mutableStateOf(false) }
val borderColor = if (isFocused) PrimaryBlue else Color.Transparent
Row(
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.border(1.dp, borderColor, RoundedCornerShape(8.dp))
.background(itemBg)
.padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$number.",
fontSize = 13.sp,
color = numberColor,
modifier = Modifier.width(22.dp)
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
color = textColor,
fontFamily = FontFamily.Monospace
),
singleLine = true,
cursorBrush = SolidColor(PrimaryBlue),
modifier = Modifier
.weight(1f)
.onFocusChanged { isFocused = it.isFocused },
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
Text(
"word",
fontSize = 15.sp,
color = hintColor.copy(alpha = 0.5f),
fontFamily = FontFamily.Monospace
)
}
innerTextField()
}
}
)
}
}

View File

@@ -0,0 +1,241 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun SeedPhraseScreen(
isDarkTheme: Boolean,
onBack: () -> Unit,
onConfirm: (List<String>) -> Unit
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val cardBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var isGenerating by remember { mutableStateOf(true) }
var hasCopied by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
delay(100)
seedPhrase = CryptoManager.generateSeedPhrase()
isGenerating = false
visible = true
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.statusBarsPadding()
) {
Column(modifier = Modifier.fillMaxSize()) {
// Simple top bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, "Back", tint = textColor)
}
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(300, easing = FastOutSlowInEasing))
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Your Recovery Phrase",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Write down these 12 words in order.\nYou'll need them to restore your account.",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(32.dp))
// Two column layout
if (isGenerating) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
strokeWidth = 2.dp,
modifier = Modifier.size(40.dp)
)
}
} else {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Left column (words 1-6)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
for (i in 0..5) {
WordItem(
number = i + 1,
word = seedPhrase[i],
isDarkTheme = isDarkTheme
)
}
}
// Right column (words 7-12)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
for (i in 6..11) {
WordItem(
number = i + 1,
word = seedPhrase[i],
isDarkTheme = isDarkTheme
)
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// Copy button
if (!isGenerating) {
TextButton(
onClick = {
hasCopied = true
scope.launch {
delay(2000)
hasCopied = false
}
}
) {
Icon(
imageVector = if (hasCopied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = null,
tint = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasCopied) "Copied" else "Copy to clipboard",
color = if (hasCopied) Color(0xFF4CAF50) else PrimaryBlue,
fontSize = 15.sp
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Continue button
Button(
onClick = { onConfirm(seedPhrase) },
enabled = !isGenerating,
modifier = Modifier
.fillMaxWidth()
.height(50.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8),
disabledContentColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFF999999)
),
shape = RoundedCornerShape(12.dp)
) {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(40.dp))
}
}
}
}
}
@Composable
private fun WordItem(
number: Int,
word: String,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val itemBg = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF5F5F5)
val numberColor = if (isDarkTheme) Color(0xFF666666) else Color(0xFFAAAAAA)
val wordColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(itemBg)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$number.",
fontSize = 15.sp,
color = numberColor,
modifier = Modifier.width(28.dp)
)
Text(
text = word,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = wordColor,
fontFamily = FontFamily.Monospace
)
}
}

View File

@@ -0,0 +1,393 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetPasswordScreen(
seedPhrase: List<String>,
isDarkTheme: Boolean,
onBack: () -> Unit,
onAccountCreated: () -> Unit
) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec)
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec)
val cardColor by animateColorAsState(if (isDarkTheme) AuthSurface else AuthSurfaceLight, animationSpec = themeAnimSpec)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var confirmPasswordVisible by remember { mutableStateOf(false) }
var isCreating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
val passwordsMatch = password == confirmPassword && password.isNotEmpty()
val passwordStrong = password.length >= 6
val canContinue = passwordsMatch && passwordStrong && !isCreating
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top Bar
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack, enabled = !isCreating) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = textColor
)
}
Spacer(modifier = Modifier.weight(1f))
Text(
text = "Set Password",
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
color = textColor
)
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(48.dp))
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
// Lock Icon
Box(
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp))
.background(PrimaryBlue.copy(alpha = 0.1f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Lock,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(40.dp)
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Protect Your Account",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.",
fontSize = 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(32.dp))
// Password Field
OutlinedTextField(
value = password,
onValueChange = {
password = it
error = null
},
label = { Text("Password") },
placeholder = { Text("Enter password") },
singleLine = true,
visualTransformation = if (passwordVisible)
VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible)
Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide" else "Show"
)
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Next
)
)
// Password strength indicator
if (password.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
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
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Confirm Password Field
OutlinedTextField(
value = confirmPassword,
onValueChange = {
confirmPassword = it
error = null
},
label = { Text("Confirm Password") },
placeholder = { Text("Re-enter password") },
singleLine = true,
visualTransformation = if (confirmPasswordVisible)
VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
Icon(
imageVector = if (confirmPasswordVisible)
Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (confirmPasswordVisible) "Hide" else "Show"
)
}
},
isError = confirmPassword.isNotEmpty() && !passwordsMatch,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
// Match indicator
if (confirmPassword.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val matchIcon = if (passwordsMatch) Icons.Default.Check else Icons.Default.Close
val matchColor = if (passwordsMatch) Color(0xFF4CAF50) else Color(0xFFE53935)
val matchText = if (passwordsMatch) "Passwords match" else "Passwords don't match"
Icon(
imageVector = matchIcon,
contentDescription = null,
tint = matchColor,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = matchText,
fontSize = 12.sp,
color = matchColor
)
}
}
// Error message
error?.let { errorMsg ->
Spacer(modifier = Modifier.height(16.dp))
Text(
text = errorMsg,
fontSize = 14.sp,
color = Color(0xFFE53935),
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.weight(1f))
// Info
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.",
fontSize = 13.sp,
color = secondaryTextColor,
lineHeight = 18.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
// Create Account Button
Button(
onClick = {
if (!passwordStrong) {
error = "Password must be at least 6 characters"
return@Button
}
if (!passwordsMatch) {
error = "Passwords don't match"
return@Button
}
isCreating = true
scope.launch {
try {
// Generate keys from seed phrase
val privateKey = CryptoManager.seedPhraseToPrivateKey(seedPhrase)
val keyPair = CryptoManager.generateKeyPairFromSeed(privateKey)
// Encrypt private key and seed phrase
val encryptedPrivateKey = CryptoManager.encryptWithPassword(
password, keyPair.privateKey
)
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
password, seedPhrase.joinToString(" ")
)
// Save account
val account = EncryptedAccount(
publicKey = keyPair.publicKey,
encryptedPrivateKey = encryptedPrivateKey,
encryptedSeedPhrase = encryptedSeedPhrase,
name = "Account 1"
)
accountManager.saveAccount(account)
accountManager.setCurrentAccount(keyPair.publicKey)
onAccountCreated()
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"
isCreating = false
}
}
},
enabled = canContinue,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = "Create Account",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}

View File

@@ -0,0 +1,296 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UnlockScreen(
isDarkTheme: Boolean,
onUnlocked: (DecryptedAccount) -> Unit
) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec)
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var isUnlocking by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var currentPublicKey by remember { mutableStateOf<String?>(null) }
// Load current account
LaunchedEffect(Unit) {
currentPublicKey = accountManager.currentPublicKey.first()
}
// Entry animation
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.2f))
// Rosetta Logo
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing))
) {
Image(
painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta",
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
)
}
Spacer(modifier = Modifier.height(32.dp))
// Title
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 200)) + slideInVertically(
initialOffsetY = { 30 },
animationSpec = tween(600, delayMillis = 200)
)
) {
Text(
text = "Welcome Back",
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
}
Spacer(modifier = Modifier.height(8.dp))
// Account info
AnimatedVisibility(
visible = visible && currentPublicKey != null,
enter = fadeIn(tween(600, delayMillis = 300))
) {
Text(
text = "Enter your password to unlock",
fontSize = 16.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(48.dp))
// Password Field
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 400)
)
) {
OutlinedTextField(
value = password,
onValueChange = {
password = it
error = null
},
label = { Text("Password") },
placeholder = { Text("Enter your password") },
singleLine = true,
visualTransformation = if (passwordVisible)
VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible)
Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide" else "Show"
)
}
},
isError = error != null,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PrimaryBlue,
unfocusedBorderColor = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0),
focusedLabelColor = PrimaryBlue,
cursorColor = PrimaryBlue,
focusedTextColor = textColor,
unfocusedTextColor = textColor,
errorBorderColor = Color(0xFFE53935)
),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
}
// Error message
AnimatedVisibility(
visible = error != null,
enter = fadeIn() + slideInVertically { -10 },
exit = fadeOut()
) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = error ?: "",
fontSize = 14.sp,
color = Color(0xFFE53935)
)
}
Spacer(modifier = Modifier.height(24.dp))
// Unlock Button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 500)
)
) {
Button(
onClick = {
if (password.isEmpty()) {
error = "Please enter your password"
return@Button
}
isUnlocking = true
scope.launch {
try {
val publicKey = currentPublicKey ?: run {
error = "No account found"
isUnlocking = false
return@launch
}
val accounts = accountManager.getAllAccounts()
val account = accounts.find { it.publicKey == publicKey }
if (account == null) {
error = "Account not found"
isUnlocking = false
return@launch
}
// Try to decrypt
val decryptedPrivateKey = CryptoManager.decryptWithPassword(
password, account.encryptedPrivateKey
)
if (decryptedPrivateKey == null) {
error = "Incorrect password"
isUnlocking = false
return@launch
}
val decryptedSeedPhrase = CryptoManager.decryptWithPassword(
password, account.encryptedSeedPhrase
)?.split(" ") ?: emptyList()
val privateKeyHash = CryptoManager.generatePrivateKeyHash(decryptedPrivateKey)
val decryptedAccount = DecryptedAccount(
publicKey = account.publicKey,
privateKey = decryptedPrivateKey,
seedPhrase = decryptedSeedPhrase,
privateKeyHash = privateKeyHash,
name = account.name
)
accountManager.setCurrentAccount(publicKey)
onUnlocked(decryptedAccount)
} catch (e: Exception) {
error = "Failed to unlock: ${e.message}"
isUnlocking = false
}
}
},
enabled = password.isNotEmpty() && !isUnlocking,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(12.dp)
) {
if (isUnlocking) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Icon(
imageVector = Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Unlock",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.weight(0.3f))
}
}
}

View File

@@ -0,0 +1,234 @@
package com.rosetta.messenger.ui.auth
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
// Auth colors
val AuthBackground = Color(0xFF1B1B1B)
val AuthBackgroundLight = Color(0xFFFFFFFF)
val AuthSurface = Color(0xFF2A2A2A)
val AuthSurfaceLight = Color(0xFFF5F5F5)
@Composable
fun WelcomeScreen(
isDarkTheme: Boolean,
onCreateSeed: () -> Unit,
onImportSeed: () -> Unit
) {
val themeAnimSpec = tween<Color>(durationMillis = 500, easing = CubicBezierEasing(0.4f, 0f, 0.2f, 1f))
val backgroundColor by animateColorAsState(if (isDarkTheme) AuthBackground else AuthBackgroundLight, animationSpec = themeAnimSpec)
val textColor by animateColorAsState(if (isDarkTheme) Color.White else Color.Black, animationSpec = themeAnimSpec)
val secondaryTextColor by animateColorAsState(if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666), animationSpec = themeAnimSpec)
// Animation for Lottie
val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json"))
val lockProgress by animateLottieCompositionAsState(
composition = lockComposition,
iterations = LottieConstants.IterateForever
)
// Entry animation
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { visible = true }
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.statusBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.15f))
// Animated Lock Icon
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600)) + scaleIn(tween(600, easing = FastOutSlowInEasing))
) {
Box(
modifier = Modifier.size(180.dp),
contentAlignment = Alignment.Center
) {
lockComposition?.let { comp ->
LottieAnimation(
composition = comp,
progress = { lockProgress },
modifier = Modifier.fillMaxSize()
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Title
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 200)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 200)
)
) {
Text(
text = "Your Keys,\nYour Messages",
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center,
lineHeight = 40.sp
)
}
Spacer(modifier = Modifier.height(16.dp))
// Subtitle
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 300)) + slideInVertically(
initialOffsetY = { 50 },
animationSpec = tween(600, delayMillis = 300)
)
) {
Text(
text = "Rosetta uses cryptographic keys\nto secure your messages.\n\nNo account registration,\nno phone number required.",
fontSize = 15.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center,
lineHeight = 24.sp,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
Spacer(modifier = Modifier.weight(0.3f))
// Create Seed Button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 400)) + slideInVertically(
initialOffsetY = { 100 },
animationSpec = tween(600, delayMillis = 400)
)
) {
Button(
onClick = onCreateSeed,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White
),
shape = RoundedCornerShape(12.dp)
) {
Icon(
imageVector = Icons.Default.Key,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Generate New Seed Phrase",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Import Seed Button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(600, delayMillis = 500)) + slideInVertically(
initialOffsetY = { 100 },
animationSpec = tween(600, delayMillis = 500)
)
) {
OutlinedButton(
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)
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
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
)
}
}
Spacer(modifier = Modifier.weight(0.15f))
}
}
}

View File

@@ -0,0 +1,36 @@
package com.rosetta.messenger.ui.onboarding
data class OnboardingPage(
val title: String,
val description: String,
val highlightWords: List<String> = emptyList()
)
val onboardingPages = listOf(
OnboardingPage(
title = "Rosetta",
description = "A local-based messaging app.\nYour data stays on your device.",
highlightWords = listOf("local-based", "your device")
),
OnboardingPage(
title = "Fast",
description = "Rosetta delivers messages faster\nthan any other application.",
highlightWords = listOf("faster")
),
OnboardingPage(
title = "Free",
description = "Rosetta is free forever. No ads.\nNo subscription fees. Ever.",
highlightWords = listOf("free forever", "No ads", "Ever")
),
OnboardingPage(
title = "Secure",
description = "Rosetta keeps your messages safe\nwith local storage and encryption.",
highlightWords = listOf("safe", "local storage", "encryption")
),
OnboardingPage(
title = "Private",
description = "No servers. No tracking.\nEverything stays on your device.",
highlightWords = listOf("No servers", "No tracking", "your device")
)
)

View File

@@ -0,0 +1,634 @@
package com.rosetta.messenger.ui.onboarding
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import com.rosetta.messenger.ui.theme.*
import kotlinx.coroutines.delay
import kotlin.math.absoluteValue
import kotlin.math.hypot
import com.airbnb.lottie.compose.*
import androidx.compose.ui.res.painterResource
import com.rosetta.messenger.R
import androidx.compose.foundation.Image
// App colors (matching React Native)
val PrimaryBlue = Color(0xFF248AE6) // primary light theme
val PrimaryBlueDark = Color(0xFF1A72C2) // primary dark theme
val LightBlue = Color(0xFF74C0FC) // lightBlue
val OnboardingBackground = Color(0xFF1E1E1E) // dark background
val OnboardingBackgroundLight = Color(0xFFFFFFFF)
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OnboardingScreen(
isDarkTheme: Boolean,
onThemeToggle: () -> Unit,
onStartMessaging: () -> Unit
) {
val pagerState = rememberPagerState(pageCount = { onboardingPages.size })
// Preload Lottie animations
val ideaComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Idea.json"))
val moneyComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Money.json"))
val lockComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/lock.json"))
val bookComposition by rememberLottieComposition(LottieCompositionSpec.Asset("lottie/Book.json"))
// Theme transition animation
var isTransitioning by remember { mutableStateOf(false) }
var transitionProgress by remember { mutableStateOf(0f) }
var clickPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var shouldUpdateStatusBar by remember { mutableStateOf(false) }
var hasInitialized by remember { mutableStateOf(false) }
var previousTheme by remember { mutableStateOf(isDarkTheme) }
var targetTheme by remember { mutableStateOf(isDarkTheme) }
LaunchedEffect(Unit) {
hasInitialized = true
}
LaunchedEffect(isTransitioning) {
if (isTransitioning) {
shouldUpdateStatusBar = false
val duration = 800f
val startTime = System.currentTimeMillis()
while (transitionProgress < 1f) {
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
}
isTransitioning = false
transitionProgress = 0f
shouldUpdateStatusBar = false
previousTheme = targetTheme
}
}
// Update status bar and navigation bar icons when wave reaches the top
val view = LocalView.current
LaunchedEffect(shouldUpdateStatusBar, isDarkTheme) {
if (shouldUpdateStatusBar && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
window.statusBarColor = android.graphics.Color.TRANSPARENT
}
}
// Animate navigation bar color with theme transition
LaunchedEffect(isTransitioning, transitionProgress, isDarkTheme) {
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()
}
}
}
val backgroundColor by animateColorAsState(
targetValue = if (isDarkTheme) OnboardingBackground else OnboardingBackgroundLight,
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing),
label = "backgroundColor"
)
val textColor by animateColorAsState(
targetValue = if (isDarkTheme) Color.White else Color.Black,
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing),
label = "textColor"
)
val secondaryTextColor by animateColorAsState(
targetValue = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666),
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing),
label = "secondaryTextColor"
)
val indicatorColor by animateColorAsState(
targetValue = if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD0D0D0),
animationSpec = if (!hasInitialized) snap() else tween(800, easing = FastOutSlowInEasing),
label = "indicatorColor"
)
Box(
modifier = Modifier
.fillMaxSize()
) {
// Base background - shows the OLD theme color during transition
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isTransitioning) {
if (previousTheme) OnboardingBackground else OnboardingBackgroundLight
} else backgroundColor)
)
// Circular reveal overlay - draws the NEW theme color expanding
if (isTransitioning) {
Canvas(modifier = Modifier.fillMaxSize()) {
val maxRadius = hypot(size.width, size.height)
val radius = maxRadius * transitionProgress
// Draw the NEW theme color expanding from click point
drawCircle(
color = if (targetTheme) OnboardingBackground else OnboardingBackgroundLight,
radius = radius,
center = clickPosition
)
}
}
// Theme toggle button in top right
ThemeToggleButton(
isDarkTheme = isDarkTheme,
onToggle = { position ->
if (!isTransitioning) {
previousTheme = isDarkTheme
targetTheme = !isDarkTheme
clickPosition = position
isTransitioning = true
onThemeToggle()
}
},
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
.statusBarsPadding()
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(0.15f))
// Animated Logo
AnimatedRosettaLogo(
pagerState = pagerState,
ideaComposition = ideaComposition,
moneyComposition = moneyComposition,
lockComposition = lockComposition,
bookComposition = bookComposition,
modifier = Modifier.size(150.dp)
)
Spacer(modifier = Modifier.height(32.dp))
// Pager for text content
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
) { page ->
OnboardingPageContent(
page = onboardingPages[page],
textColor = textColor,
secondaryTextColor = secondaryTextColor,
highlightColor = PrimaryBlue,
pageOffset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue
)
}
Spacer(modifier = Modifier.height(24.dp))
// Page indicators
PagerIndicator(
pageCount = onboardingPages.size,
currentPage = pagerState.currentPage,
selectedColor = PrimaryBlue,
unselectedColor = indicatorColor
)
Spacer(modifier = Modifier.weight(0.3f))
// Start messaging button
StartMessagingButton(
onClick = onStartMessaging,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(48.dp))
}
}
}
@Composable
fun ThemeToggleButton(
isDarkTheme: Boolean,
onToggle: (androidx.compose.ui.geometry.Offset) -> Unit,
modifier: Modifier = Modifier
) {
val rotation by animateFloatAsState(
targetValue = if (isDarkTheme) 360f else 0f,
animationSpec = spring(
dampingRatio = 0.6f,
stiffness = Spring.StiffnessLow
),
label = "rotation"
)
val scale by animateFloatAsState(
targetValue = 1f,
animationSpec = spring(
dampingRatio = 0.4f,
stiffness = Spring.StiffnessMedium
),
label = "scale"
)
val iconColor by animateColorAsState(
targetValue = if (isDarkTheme) Color.White else Color(0xFF2A2A2A),
animationSpec = tween(800, easing = FastOutSlowInEasing),
label = "iconColor"
)
var buttonPosition by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
var isClickable by remember { mutableStateOf(true) }
LaunchedEffect(isDarkTheme) {
isClickable = false
delay(800)
isClickable = true
}
IconButton(
onClick = { if (isClickable) onToggle(buttonPosition) },
enabled = isClickable,
modifier = modifier
.size(48.dp)
.onGloballyPositioned { coordinates ->
val bounds = coordinates.boundsInWindow()
buttonPosition = androidx.compose.ui.geometry.Offset(
x = bounds.center.x,
y = bounds.center.y
)
}
) {
Box(
modifier = Modifier
.size(24.dp)
.scale(scale)
.rotate(rotation),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isDarkTheme) Icons.Default.LightMode else Icons.Default.DarkMode,
contentDescription = if (isDarkTheme) "Switch to Light Mode" else "Switch to Dark Mode",
tint = iconColor,
modifier = Modifier.size(24.dp)
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AnimatedRosettaLogo(
pagerState: PagerState,
ideaComposition: Any?,
moneyComposition: Any?,
lockComposition: Any?,
bookComposition: Any?,
modifier: Modifier = Modifier
) {
val currentPage = pagerState.currentPage
val pageOffset = pagerState.currentPageOffsetFraction
// Animate scale and alpha based on swipe
val scale by animateFloatAsState(
targetValue = 1f - (pageOffset.absoluteValue * 0.08f),
animationSpec = tween(400, easing = FastOutSlowInEasing),
label = "scale"
)
val alpha by animateFloatAsState(
targetValue = 1f - (pageOffset.absoluteValue * 0.3f),
animationSpec = tween(400, easing = FastOutSlowInEasing),
label = "alpha"
)
Box(
modifier = modifier
.scale(scale)
.graphicsLayer { this.alpha = alpha },
contentAlignment = Alignment.Center
) {
// Pre-render all animations to avoid lag
Box(modifier = Modifier.fillMaxSize()) {
// Rosetta icon (page 0)
if (currentPage == 0) {
Image(
painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta Logo",
modifier = Modifier
.fillMaxSize()
.clip(CircleShape)
)
}
// Fast page - idea animation (page 1)
ideaComposition?.let { comp ->
val lottieComp = comp as? com.airbnb.lottie.LottieComposition
val progress by animateLottieCompositionAsState(
composition = lottieComp,
iterations = 1,
isPlaying = currentPage == 1
)
if (currentPage == 1) {
LottieAnimation(
composition = lottieComp,
progress = { progress },
modifier = Modifier.fillMaxSize()
)
}
}
// Free page - money animation (page 2)
moneyComposition?.let { comp ->
val lottieComp = comp as? com.airbnb.lottie.LottieComposition
val progress by animateLottieCompositionAsState(
composition = lottieComp,
iterations = 1,
isPlaying = currentPage == 2
)
if (currentPage == 2) {
LottieAnimation(
composition = lottieComp,
progress = { progress },
modifier = Modifier.fillMaxSize()
)
}
}
// Secure page - lock animation (page 3)
lockComposition?.let { comp ->
val lottieComp = comp as? com.airbnb.lottie.LottieComposition
val progress by animateLottieCompositionAsState(
composition = lottieComp,
iterations = 1,
isPlaying = currentPage == 3
)
if (currentPage == 3) {
LottieAnimation(
composition = lottieComp,
progress = { progress },
modifier = Modifier.fillMaxSize()
)
}
}
// Private page - book animation (page 4)
bookComposition?.let { comp ->
val lottieComp = comp as? com.airbnb.lottie.LottieComposition
val progress by animateLottieCompositionAsState(
composition = lottieComp,
iterations = 1,
isPlaying = currentPage == 4
)
if (currentPage == 4) {
LottieAnimation(
composition = lottieComp,
progress = { progress },
modifier = Modifier.fillMaxSize()
)
}
}
}
}
}
@Composable
fun OnboardingPageContent(
page: OnboardingPage,
textColor: Color,
secondaryTextColor: Color,
highlightColor: Color,
pageOffset: Float
) {
val alpha by animateFloatAsState(
targetValue = 1f - (pageOffset * 0.7f).coerceIn(0f, 1f),
animationSpec = tween(400, easing = FastOutSlowInEasing),
label = "alpha"
)
val scale by animateFloatAsState(
targetValue = 1f - (pageOffset * 0.1f).coerceIn(0f, 1f),
animationSpec = tween(400, easing = FastOutSlowInEasing),
label = "scale"
)
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
this.alpha = alpha
scaleX = scale
scaleY = scale
},
horizontalAlignment = Alignment.CenterHorizontally
) {
// Title
Text(
text = page.title,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(12.dp))
// Description with highlighted words
val annotatedDescription = buildAnnotatedString {
var currentIndex = 0
val description = page.description
// Find and highlight words
page.highlightWords.forEach { word ->
val startIndex = description.indexOf(word, currentIndex, ignoreCase = true)
if (startIndex >= 0) {
// Add text before the word
if (startIndex > currentIndex) {
withStyle(SpanStyle(color = secondaryTextColor)) {
append(description.substring(currentIndex, startIndex))
}
}
// Add highlighted word
withStyle(SpanStyle(color = highlightColor, fontWeight = FontWeight.SemiBold)) {
append(description.substring(startIndex, startIndex + word.length))
}
currentIndex = startIndex + word.length
}
}
// Add remaining text
if (currentIndex < description.length) {
withStyle(SpanStyle(color = secondaryTextColor)) {
append(description.substring(currentIndex))
}
}
}
Text(
text = annotatedDescription,
fontSize = 17.sp,
textAlign = TextAlign.Center,
lineHeight = 24.sp
)
}
}
@Composable
fun PagerIndicator(
pageCount: Int,
currentPage: Int,
selectedColor: Color,
unselectedColor: Color,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
repeat(pageCount) { index ->
val isSelected = index == currentPage
val width by animateDpAsState(
targetValue = if (isSelected) 20.dp else 8.dp,
animationSpec = spring(dampingRatio = 0.8f),
label = "indicatorWidth"
)
Box(
modifier = Modifier
.height(8.dp)
.width(width)
.clip(CircleShape)
.background(if (isSelected) selectedColor else unselectedColor)
)
}
}
}
@Composable
fun StartMessagingButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
// Shining effect animation
val infiniteTransition = rememberInfiniteTransition(label = "shine")
val shimmerTranslate by infiniteTransition.animateFloat(
initialValue = -0.5f,
targetValue = 1.5f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "shimmerTranslate"
)
Surface(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.height(54.dp),
shape = RoundedCornerShape(12.dp),
color = PrimaryBlue
) {
Box(
modifier = Modifier
.fillMaxSize()
.drawWithContent {
drawContent()
// Draw shimmer on top
val shimmerWidth = size.width * 0.6f
val shimmerStart = shimmerTranslate * size.width
drawRect(
brush = Brush.linearGradient(
colors = listOf(
Color.White.copy(alpha = 0f),
Color.White.copy(alpha = 0.3f),
Color.White.copy(alpha = 0f)
),
start = Offset(shimmerStart, 0f),
end = Offset(shimmerStart + shimmerWidth, size.height)
)
)
},
contentAlignment = Alignment.Center
) {
Text(
text = "Start Messaging",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
}
}

View File

@@ -0,0 +1,81 @@
package com.rosetta.messenger.ui.splash
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.rosetta.messenger.R
import kotlinx.coroutines.delay
@Composable
fun SplashScreen(
isDarkTheme: Boolean,
onSplashComplete: () -> Unit
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1B1B1B) else Color.White
// Animation states
var startAnimation by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (startAnimation) 1f else 0f,
animationSpec = spring(
dampingRatio = 0.5f,
stiffness = Spring.StiffnessLow
),
label = "scale"
)
val pulseScale by rememberInfiniteTransition(label = "pulse").animateFloat(
initialValue = 1f,
targetValue = 1.1f,
animationSpec = infiniteRepeatable(
animation = tween(800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "pulseScale"
)
LaunchedEffect(Unit) {
startAnimation = true
delay(2000) // Show splash for 2 seconds
onSplashComplete()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
// Glow effect behind logo
Box(
modifier = Modifier
.size(180.dp)
.scale(scale * pulseScale)
.background(
color = Color(0xFF54A9EB).copy(alpha = 0.2f),
shape = CircleShape
)
)
// Main logo
Image(
painter = painterResource(id = R.drawable.rosetta_icon),
contentDescription = "Rosetta Logo",
modifier = Modifier
.size(150.dp)
.scale(scale)
.clip(CircleShape)
)
}
}

View File

@@ -0,0 +1,41 @@
package com.rosetta.messenger.ui.theme
import androidx.compose.ui.graphics.Color
// Light theme colors
val LightBackground = Color(0xFFFFFFFF)
val LightBackgroundSecondary = Color(0xFFF2F3F5)
val LightSurface = Color(0xFFF5F5F5)
val LightText = Color(0xFF000000)
val LightTextSecondary = Color(0xFF666666)
val LightTextTertiary = Color(0xFF999999)
val LightBlue = Color(0xFF74C0FC)
val LightBorder = Color(0xFFE0E0E0)
val LightDivider = Color(0xFFEEEEEE)
val LightMessageBackground = Color(0xFFF5F5F5)
val LightMessageBackgroundOwn = Color(0xFFDCF8C6)
val LightPrimary = Color(0xFF248AE6)
val LightPrimaryLight = Color(0xFF73C0FC)
val LightInputBackground = Color(0xFFF2F3F5)
// Dark theme colors
val DarkBackground = Color(0xFF1E1E1E)
val DarkBackgroundSecondary = Color(0xFF2A2A2A)
val DarkSurface = Color(0xFF242424)
val DarkText = Color(0xFFFFFFFF)
val DarkTextSecondary = Color(0xFF8E8E93)
val DarkTextTertiary = Color(0xFF666666)
val DarkBorder = Color(0xFF2E2E2E)
val DarkDivider = Color(0xFF333333)
val DarkMessageBackground = Color(0xFF2A2A2A)
val DarkMessageBackgroundOwn = Color(0xFF263341)
val DarkPrimary = Color(0xFF238BE6)
val DarkPrimaryLight = Color(0xFF5BA8F0)
val DarkInputBackground = Color(0xFF2A2A2A)
// Shared colors
val Accent = Color(0xFFE91E63)
val Error = Color(0xFFFF3B30)
val Success = Color(0xFF34C759)
val Warning = Color(0xFFFF9500)
val OnlineIndicator = Color(0xFF34C759)

View File

@@ -0,0 +1,82 @@
package com.rosetta.messenger.ui.theme
import android.graphics.Color as AndroidColor
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import kotlinx.coroutines.delay
private val DarkColorScheme = darkColorScheme(
primary = DarkPrimary,
secondary = Accent,
tertiary = DarkPrimaryLight,
background = DarkBackground,
surface = DarkSurface,
error = Error,
onPrimary = DarkText,
onSecondary = DarkText,
onTertiary = DarkText,
onBackground = DarkText,
onSurface = DarkText,
onError = DarkText,
)
private val LightColorScheme = lightColorScheme(
primary = LightPrimary,
secondary = Accent,
tertiary = LightPrimaryLight,
background = LightBackground,
surface = LightSurface,
error = Error,
onPrimary = LightText,
onSecondary = LightText,
onTertiary = LightText,
onBackground = LightText,
onSurface = LightText,
onError = LightText,
)
@Composable
fun RosettaAndroidTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = false,
@Suppress("UNUSED_PARAMETER") animated: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
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()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,38 @@
package com.rosetta.messenger.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
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,
letterSpacing = 0.sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp
)
)