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

21
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# Rosetta Android - Project Setup
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [ ] Clarify Project Requirements
Project: Android application using Kotlin named rosetta-android
- [ ] Scaffold the Project
Creating Android project structure with Kotlin
- [ ] Customize the Project
- [ ] Install Required Extensions
- [ ] Compile the Project
- [ ] Create and Run Task
- [ ] Launch the Project
- [ ] Ensure Documentation is Complete

106
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,106 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.rosetta.messenger"
compileSdk = 34
defaultConfig {
applicationId = "com.rosetta.messenger"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.4"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.2")
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
// Accompanist for pager and animations
implementation("com.google.accompanist:accompanist-pager:0.32.0")
implementation("com.google.accompanist:accompanist-pager-indicators:0.32.0")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Foundation for pager (Compose)
implementation("androidx.compose.foundation:foundation:1.5.4")
// Icons extended
implementation("androidx.compose.material:material-icons-extended:1.5.4")
// Lottie for animations
implementation("com.airbnb.android:lottie-compose:6.1.0")
// Coil for image loading
implementation("io.coil-kt:coil-compose:2.5.0")
// Crypto libraries for key generation
implementation("org.bitcoinj:bitcoinj-core:0.16.2")
implementation("org.bouncycastle:bcprov-jdk15to18:1.77")
// Security for encrypted storage
implementation("androidx.security:security-crypto:1.1.0-alpha06")
// Room for database
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RosettaAndroid"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.RosettaAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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
)
)

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#1B1B1B"/>
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:inset="15%">
<bitmap
android:src="@drawable/rosetta_icon"
android:gravity="fill"/>
</inset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Rosetta</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RosettaAndroid" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/splash_background</item>
<item name="android:statusBarColor">@color/splash_background</item>
<item name="android:navigationBarColor">@color/splash_background</item>
</style>
<color name="splash_background">#1B1B1B</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="."/>
</full-backup-content>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<include domain="database" path="."/>
</cloud-backup>
</data-extraction-rules>

9
build.gradle.kts Normal file
View File

@@ -0,0 +1,9 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
}
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}

17
gradle.properties Normal file
View File

@@ -0,0 +1,17 @@
# Project-wide Gradle settings.
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete"
kotlin.code.style=official
# Increase heap size for Gradle
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

17
settings.gradle.kts Normal file
View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "rosetta-android"
include(":app")

23
watch.sh Normal file
View File

@@ -0,0 +1,23 @@
#!/bin/bash
echo "🔄 Watching for changes and auto-rebuilding..."
echo "📱 Device: $(adb devices | grep device | head -1)"
echo ""
# Function to build and install
build_and_install() {
echo "🔨 Building and installing..."
cd /Users/ruslanmakhmatov/Desktop/Work/rosette-app/rosetta-android
./gradlew installDebug --quiet
if [ $? -eq 0 ]; then
echo "✅ Successfully installed at $(date '+%H:%M:%S')"
else
echo "❌ Build failed at $(date '+%H:%M:%S')"
fi
echo ""
}
# Watch for changes in Kotlin files
fswatch -o app/src/main/java/com/rosetta/messenger/**/*.kt | while read; do
build_and_install
done