From feffda519f2304ded23adfacc46fdd3f7e0c4267 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 27 Apr 2026 12:14:54 +0700 Subject: [PATCH] add simple create identity flow --- composeApp/build.gradle.kts | 4 +- .../androidMain/kotlin/su/reya/coop/App.kt | 29 +++-- .../kotlin/su/reya/coop/MainActivity.kt | 8 -- .../kotlin/su/reya/coop/Navigation.kt | 20 +++ .../kotlin/su/reya/coop/Screens.kt | 82 ------------ .../kotlin/su/reya/coop/screens/ChatScreen.kt | 15 +++ .../kotlin/su/reya/coop/screens/HomeScreen.kt | 26 ++++ .../su/reya/coop/screens/ImportScreen.kt | 15 +++ .../su/reya/coop/screens/NewIdentityScreen.kt | 118 ++++++++++++++++++ .../su/reya/coop/screens/OnboardingScreen.kt | 30 +++++ .../kotlin/su/reya/coop/Platform.android.kt | 9 -- .../commonMain/kotlin/su/reya/coop/Nostr.kt | 30 +++++ .../kotlin/su/reya/coop/NostrViewModel.kt | 22 ++++ .../kotlin/su/reya/coop/Platform.ios.kt | 9 -- 14 files changed, 301 insertions(+), 116 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt delete mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt delete mode 100644 shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt delete mode 100644 shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 2a03a37..08b3b24 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -23,7 +23,9 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0") implementation("androidx.datastore:datastore-preferences:1.2.1") implementation("androidx.datastore:datastore-preferences-core:1.2.1") - implementation("org.jetbrains.compose.material3:material3*:1.10.0-alpha05") + implementation("org.jetbrains.compose.material3:material3:1.11.0-alpha07") + implementation("io.coil-kt.coil3:coil-compose:3.4.0") + implementation("io.coil-kt.coil3:coil-network-okhttp:3.4.0") } commonMain.dependencies { implementation(libs.compose.runtime) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index f9aeb8f..a34d159 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -1,6 +1,7 @@ package su.reya.coop -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -15,7 +16,13 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import kotlinx.coroutines.flow.flow import su.reya.coop.coop.storage.SecretStore +import su.reya.coop.screens.ChatScreen +import su.reya.coop.screens.HomeScreen +import su.reya.coop.screens.ImportScreen +import su.reya.coop.screens.NewIdentityScreen +import su.reya.coop.screens.OnboardingScreen +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { val context = LocalContext.current @@ -27,7 +34,7 @@ fun App(dbPath: String) { viewModel.initAndConnect(dbPath) } - MaterialTheme { + MaterialExpressiveTheme { rememberCoroutineScope() val navController = rememberNavController() @@ -41,24 +48,32 @@ fun App(dbPath: String) { if (hasSecret == null) { // Loading state - return@MaterialTheme + return@MaterialExpressiveTheme } NavHost( navController = navController, - startDestination = if (hasSecret == true) Screen.Onboarding else Screen.Home + startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding ) { composable { backStackEntry -> OnboardingScreen( onOpenImport = { navController.navigate(Screen.Import) }, - onOpenNew = { navController.navigate(Screen.New) } + onOpenNew = { navController.navigate(Screen.NewIdentity) } ) } composable { backStackEntry -> ImportScreen() } - composable { backStackEntry -> - NewScreen() + composable { backStackEntry -> + val isCreating by viewModel.isCreating.collectAsState() + + NewIdentityScreen( + isLoading = isCreating, + onSave = { name, bio, uri -> + viewModel.createIdentity(name, bio, uri?.toString()) + navController.navigate(Screen.Home) + } + ) } composable { backStackEntry -> HomeScreen( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt index d2240f7..4d00430 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/MainActivity.kt @@ -4,8 +4,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview import java.io.File class MainActivity : ComponentActivity() { @@ -22,9 +20,3 @@ class MainActivity : ComponentActivity() { } } } - -@Preview -@Composable -fun AppAndroidPreview() { - App() -} \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt new file mode 100644 index 0000000..e08a11c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -0,0 +1,20 @@ +package su.reya.coop + +import kotlinx.serialization.Serializable + +sealed interface Screen { + @Serializable + data object Home : Screen + + @Serializable + data class Chat(val id: String) : Screen + + @Serializable + data object Onboarding : Screen + + @Serializable + data object Import : Screen + + @Serializable + data object NewIdentity : Screen +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt deleted file mode 100644 index fdf32ad..0000000 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Screens.kt +++ /dev/null @@ -1,82 +0,0 @@ -package su.reya.coop - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import kotlinx.serialization.Serializable - -sealed interface Screen { - @Serializable - data object Home : Screen - - @Serializable - data class Chat(val id: String) : Screen - - @Serializable - data object Onboarding : Screen - - @Serializable - data object Import : Screen - - @Serializable - data object New : Screen -} - -@Composable -fun HomeScreen(onOpenChat: (String) -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Home Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = { onOpenChat("123") }) { - Text("Open Chat 123") - } - } - } -} - -@Composable -fun ChatScreen(id: String) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Chat Screen (ID: $id)") - } -} - -@Composable -fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Onboarding Screen") - Spacer(modifier = Modifier.height(16.dp)) - Button(onClick = onOpenImport) { - Text("Import") - } - Spacer(modifier = Modifier.height(8.dp)) - Button(onClick = onOpenNew) { - Text("New") - } - } - } -} - -@Composable -fun ImportScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Import Screen") - } -} - -@Composable -fun NewScreen() { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("New Screen") - } -} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt new file mode 100644 index 0000000..5677e4b --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -0,0 +1,15 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun ChatScreen(id: String) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Chat Screen (ID: $id)") + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt new file mode 100644 index 0000000..ad47bfb --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -0,0 +1,26 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun HomeScreen(onOpenChat: (String) -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Home Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { onOpenChat("123") }) { + Text("Open Chat 123") + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt new file mode 100644 index 0000000..10526dd --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -0,0 +1,15 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +fun ImportScreen() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Import Screen") + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt new file mode 100644 index 0000000..4486217 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -0,0 +1,118 @@ +package su.reya.coop.screens + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun NewIdentityScreen( + isLoading: Boolean, + onSave: (name: String, bio: String, picture: Uri?) -> Unit +) { + var name by remember { mutableStateOf("") } + var bio by remember { mutableStateOf("") } + var picture by remember { mutableStateOf(null) } + + val launcher = + rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? -> + picture = uri + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "New Identity", + style = MaterialTheme.typography.headlineMediumEmphasized + ) + Box( + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + contentAlignment = Alignment.Center + ) { + if (picture != null) { + AsyncImage( + model = picture, + contentDescription = "Profile picture", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxSize() + + ) { + // + } + } + } + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + OutlinedTextField( + value = bio, + onValueChange = { bio = it }, + label = { Text("Bio:") }, + modifier = Modifier + .fillMaxWidth() + .height(150.dp), + minLines = 3, + ) + Spacer(modifier = Modifier.weight(1f)) + Button( + onClick = { + onSave(name, bio, picture) + }, + modifier = Modifier.fillMaxWidth(), + enabled = name.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text("Save & Continue") + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt new file mode 100644 index 0000000..2f9d7f0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/OnboardingScreen.kt @@ -0,0 +1,30 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun OnboardingScreen(onOpenImport: () -> Unit, onOpenNew: () -> Unit) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Onboarding Screen") + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onOpenImport) { + Text("Import") + } + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = onOpenNew) { + Text("New") + } + } + } +} diff --git a/shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt b/shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt deleted file mode 100644 index ee2a4fe..0000000 --- a/shared/src/androidMain/kotlin/su/reya/coop/Platform.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package su.reya.coop - -import android.os.Build - -class AndroidPlatform : Platform { - override val name: String = "Android ${Build.VERSION.SDK_INT}" -} - -actual fun getPlatform(): Platform = AndroidPlatform() \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 95777ca..48d52ca 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -3,13 +3,22 @@ package su.reya.coop import rust.nostr.sdk.Client import rust.nostr.sdk.ClientBuilder import rust.nostr.sdk.ClientOptions +import rust.nostr.sdk.EventBuilder +import rust.nostr.sdk.Keys +import rust.nostr.sdk.Metadata +import rust.nostr.sdk.MetadataRecord import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip +import rust.nostr.sdk.NostrSigner import rust.nostr.sdk.RelayUrl class Nostr { var client: Client? = null private set + var signer: NostrSigner? = null + private set + var deviceSigner: NostrSigner? = null + private set fun init(dbPath: String) { val lmdb = NostrDatabase.lmdb(dbPath) @@ -29,4 +38,25 @@ class Nostr { suspend fun disconnect() { this.client?.shutdown() } + + suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) { + signer = NostrSigner.keys(keys) + + // Construct metadata + val metadata = Metadata.fromRecord( + MetadataRecord( + name = name, + displayName = name, + about = bio, + picture = picture + ) + ) + + // Construct event and sign it + val builder = EventBuilder.metadata(metadata).build(keys.publicKey()) + val event = this.signer?.signEvent(builder) ?: return + + // Send event to relays + this.client?.sendEvent(event) + } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index c56f4b0..5ba5358 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import rust.nostr.sdk.Keys import su.reya.coop.storage.SecretStorage class NostrViewModel( @@ -16,6 +17,9 @@ class NostrViewModel( private val _isConnected = MutableStateFlow(false) val isConnected = _isConnected.asStateFlow() + private val _isCreating = MutableStateFlow(false) + val isCreating = _isCreating.asStateFlow() + fun initAndConnect(dbPath: String) { // Initialize nostr client nostr.init(dbPath) @@ -32,6 +36,24 @@ class NostrViewModel( } } + fun createIdentity(name: String, bio: String, picture: String?) { + viewModelScope.launch { + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + // Set loading state + _isCreating.value = true + // Create identity + nostr.createIdentity(keys, name, bio, picture) + // Save secret to the secret storage + secretStore.set("user_signer", secret) + } catch (e: Exception) { + _isCreating.value = false + println(e) + } + } + } + override fun onCleared() { super.onCleared() // Ensure all relays are disconnect diff --git a/shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt b/shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt deleted file mode 100644 index aaf7b28..0000000 --- a/shared/src/iosMain/kotlin/su/reya/coop/Platform.ios.kt +++ /dev/null @@ -1,9 +0,0 @@ -package su.reya.coop - -import platform.UIKit.UIDevice - -class IOSPlatform: Platform { - override val name: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion -} - -actual fun getPlatform(): Platform = IOSPlatform() \ No newline at end of file