add nostr view model

This commit is contained in:
2026-04-25 08:52:42 +07:00
parent 8c6b70304d
commit 3240382498
11 changed files with 100 additions and 94 deletions

View File

@@ -23,6 +23,7 @@ kotlin {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences:1.2.1")
implementation("androidx.datastore:datastore-preferences-core:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1")
implementation("org.jetbrains.compose.material3:material3*:1.10.0-alpha05")
} }
commonMain.dependencies { commonMain.dependencies {
implementation(libs.compose.runtime) implementation(libs.compose.runtime)

View File

@@ -1,72 +1,53 @@
package su.reya.coop package su.reya.coop
import android.content.Context
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.datastore.core.DataStore import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute import androidx.navigation.toRoute
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch import su.reya.coop.coop.storage.SecretStore
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
private val FIRST_TIME_KEY = booleanPreferencesKey("first_time")
@Composable @Composable
fun App() { fun App(dbPath: String) {
val context = LocalContext.current
val nostr = remember { Nostr() }
val secretStore = remember { SecretStore(context) }
val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) }
LaunchedEffect(Unit) {
viewModel.initAndConnect(dbPath)
}
MaterialTheme { MaterialTheme {
val context = LocalContext.current rememberCoroutineScope()
val scope = rememberCoroutineScope()
val navController = rememberNavController() val navController = rememberNavController()
val isFirstTimeFlow = remember { // Get user's signer status
context.dataStore.data.map { preferences -> val hasSecretFlow = remember {
preferences[FIRST_TIME_KEY] ?: true flow {
emit(secretStore.has("user_signer"))
} }
} }
val isFirstTime by isFirstTimeFlow.collectAsState(initial = null) val hasSecret by hasSecretFlow.collectAsState(initial = null)
if (isFirstTime == null) { if (hasSecret == null) {
// Loading state // Loading state
return@MaterialTheme return@MaterialTheme
} }
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = if (isFirstTime == true) Screen.Welcome else Screen.Home startDestination = if (hasSecret == true) Screen.Onboarding else Screen.Home
) { ) {
composable<Screen.Welcome> { backStackEntry ->
WelcomeScreen(onContinue = {
scope.launch {
context.dataStore.edit { settings ->
settings[FIRST_TIME_KEY] = false
}
navController.navigate(Screen.Home) {
popUpTo<Screen.Welcome> { inclusive = true }
}
}
})
}
composable<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }
)
}
composable<Screen.Chat> { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(id = chat.id)
}
composable<Screen.Onboarding> { backStackEntry -> composable<Screen.Onboarding> { backStackEntry ->
OnboardingScreen( OnboardingScreen(
onOpenImport = { navController.navigate(Screen.Import) }, onOpenImport = { navController.navigate(Screen.Import) },
@@ -79,6 +60,15 @@ fun App() {
composable<Screen.New> { backStackEntry -> composable<Screen.New> { backStackEntry ->
NewScreen() NewScreen()
} }
composable<Screen.Home> { backStackEntry ->
HomeScreen(
onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }
)
}
composable<Screen.Chat> { backStackEntry ->
val chat: Screen.Chat = backStackEntry.toRoute()
ChatScreen(id = chat.id)
}
} }
} }
} }

View File

@@ -6,13 +6,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import java.io.File import java.io.File
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val nostr = Nostr()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -21,16 +17,8 @@ class MainActivity : ComponentActivity() {
val dbDir = File(filesDir, "nostr") val dbDir = File(filesDir, "nostr")
dbDir.mkdirs() dbDir.mkdirs()
// Initialize nostr client
nostr.init(dbDir.absolutePath)
// Connect to bootstrap relays
lifecycleScope.launch {
nostr.connect()
}
setContent { setContent {
App() App(dbDir.absolutePath)
} }
} }
} }

View File

@@ -14,9 +14,6 @@ import androidx.compose.ui.unit.dp
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
sealed interface Screen { sealed interface Screen {
@Serializable
data object Welcome : Screen
@Serializable @Serializable
data object Home : Screen data object Home : Screen
@@ -33,19 +30,6 @@ sealed interface Screen {
data object New : Screen data object New : Screen
} }
@Composable
fun WelcomeScreen(onContinue: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Welcome Screen")
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onContinue) {
Text("Get Started")
}
}
}
}
@Composable @Composable
fun HomeScreen(onOpenChat: (String) -> Unit) { fun HomeScreen(onOpenChat: (String) -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {

View File

@@ -5,13 +5,14 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import su.reya.coop.storage.SecretStorage
private val Context.dataStore by preferencesDataStore("secret_store") private val Context.dataStore by preferencesDataStore("secret_store")
class SecretStore(private val context: Context) { class SecretStore(private val context: Context) : SecretStorage {
private val crypto = SecretCrypto() private val crypto = SecretCrypto()
suspend fun set(key: String, value: String) { override suspend fun set(key: String, value: String) {
val entry = crypto.encrypt(value) val entry = crypto.encrypt(value)
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
@@ -20,7 +21,7 @@ class SecretStore(private val context: Context) {
} }
} }
suspend fun get(key: String): String? { override suspend fun get(key: String): String? {
val prefs = context.dataStore.data.first() val prefs = context.dataStore.data.first()
val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null val encrypted = prefs[stringPreferencesKey("${key}_encrypted")] ?: return null
val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null val iv = prefs[stringPreferencesKey("${key}_iv")] ?: return null
@@ -28,15 +29,15 @@ class SecretStore(private val context: Context) {
return crypto.decrypt(SecretEntry(encrypted, iv)) return crypto.decrypt(SecretEntry(encrypted, iv))
} }
suspend fun clear(name: String) { override suspend fun clear(key: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
prefs.remove(stringPreferencesKey("${name}_encrypted")) prefs.remove(stringPreferencesKey("${key}_encrypted"))
prefs.remove(stringPreferencesKey("${name}_iv")) prefs.remove(stringPreferencesKey("${key}_iv"))
} }
} }
suspend fun has(name: String): Boolean { override suspend fun has(key: String): Boolean {
val prefs = context.dataStore.data.first() val prefs = context.dataStore.data.first()
return prefs[stringPreferencesKey("${name}_encrypted")] != null return prefs[stringPreferencesKey("${key}_encrypted")] != null
} }
} }

View File

@@ -24,6 +24,8 @@ kotlin {
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation("org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3") implementation("org.rust-nostr:nostr-sdk-kmp:0.44.3")
} }
commonTest.dependencies { commonTest.dependencies {

View File

@@ -1,9 +0,0 @@
package su.reya.coop
class Greeting {
private val platform = getPlatform()
fun greet(): String {
return "Hello, ${platform.name}!"
}
}

View File

@@ -25,4 +25,8 @@ class Nostr {
this.client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) this.client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
this.client?.connect() this.client?.connect()
} }
suspend fun disconnect() {
this.client?.shutdown()
}
} }

View File

@@ -0,0 +1,44 @@
package su.reya.coop
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import su.reya.coop.storage.SecretStorage
class NostrViewModel(
private val nostr: Nostr,
private val secretStore: SecretStorage
) : ViewModel() {
private val _isConnected = MutableStateFlow(false)
val isConnected = _isConnected.asStateFlow()
fun initAndConnect(dbPath: String) {
// Initialize nostr client
nostr.init(dbPath)
viewModelScope.launch {
try {
// Connect to bootstrap relays
nostr.connect()
_isConnected.value = true
} catch (e: Exception) {
_isConnected.value = false
println(e)
}
}
}
override fun onCleared() {
super.onCleared()
// Ensure all relays are disconnect
viewModelScope.launch {
withContext(NonCancellable) {
nostr.disconnect()
}
}
}
}

View File

@@ -1,7 +0,0 @@
package su.reya.coop
interface Platform {
val name: String
}
expect fun getPlatform(): Platform

View File

@@ -0,0 +1,8 @@
package su.reya.coop.storage
interface SecretStorage {
suspend fun get(key: String): String?
suspend fun set(key: String, value: String)
suspend fun clear(key: String)
suspend fun has(key: String): Boolean
}