From b1d9135394749e5bf6f1c3eefc15e5237d9b0a7e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 1 Jun 2026 20:25:32 +0700 Subject: [PATCH 1/2] fix navigation after login --- .../androidMain/kotlin/su/reya/coop/App.kt | 19 +--- .../su/reya/coop/screens/ImportScreen.kt | 38 +++---- .../su/reya/coop/screens/NewIdentityScreen.kt | 44 +++++-- .../kotlin/su/reya/coop/NostrViewModel.kt | 107 +++++++++--------- 4 files changed, 108 insertions(+), 100 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 0d7751c..3187eaf 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -189,25 +189,10 @@ fun App(viewModel: NostrViewModel) { OnboardingScreen() } entry { - ImportScreen( - onSave = { secret -> - viewModel.importIdentity(secret) - } - ) + ImportScreen() } entry { - NewIdentityScreen( - onSave = { name, bio, uri -> - val contentType = - uri?.let { context.contentResolver.getType(it) } - val picture = uri?.let { - context.contentResolver.openInputStream(it)?.use { input -> - input.readBytes() - } - } - viewModel.createIdentity(name, bio, picture, contentType) - } - ) + NewIdentityScreen() } entry { key -> ChatScreen(id = key.id) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index 695d8d5..c2d4af5 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -49,10 +48,11 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_scanner -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.Keys @@ -69,32 +69,28 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun ImportScreen( - onSave: (secret: String) -> Unit -) { +fun ImportScreen() { val snackbarHostState = LocalSnackbarHostState.current val navigator = LocalNavigator.current val qrScanResult = LocalScanResult.current val focusManager = LocalFocusManager.current val viewModel = LocalNostrViewModel.current + val scope = rememberCoroutineScope() + val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false) var secret by remember { mutableStateOf("") } var pubkey by remember { mutableStateOf(null) } + + // Get metadata when pubkey changes val metadata by remember(pubkey) { - if (pubkey != null) { - viewModel.getMetadata(pubkey!!) - } else { - MutableStateFlow(null) - } - }.collectAsState(null) + pubkey?.let(viewModel::getMetadata) ?: flowOf(null) + }.collectAsStateWithLifecycle(null) val profile = metadata?.asRecord() val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" val picture = profile?.picture - val isLoading by viewModel.isCreating.collectAsState() - LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> runCatching { @@ -246,20 +242,24 @@ fun ImportScreen( Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { - if (pubkey == null) { - scope.launch { + scope.launch { + if (pubkey == null) { viewModel.verifyIdentity(secret).let { pubkey = it } + } else { + // Import the identity + viewModel.importIdentity(secret) + // Navigate to the home screen + navigator.navigate(Screen.Home) } - } else { - onSave(secret) } + }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = secret.isNotBlank() && !isLoading, + enabled = secret.isNotBlank() && !isLoggedIn, ) { - if (isLoading) { + if (isLoggedIn) { LoadingIndicator() } else { Text( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 0cdc0e7..71d6e5a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -36,45 +36,50 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope 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.graphics.SolidColor import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_plus +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource import su.reya.coop.LocalNavigator import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -fun NewIdentityScreen( - onSave: (name: String, bio: String?, picture: Uri?) -> Unit -) { - +fun NewIdentityScreen() { + val context = LocalContext.current val snackbarHostState = LocalSnackbarHostState.current val focusManager = LocalFocusManager.current val navigator = LocalNavigator.current val viewModel = LocalNostrViewModel.current + val isLoggedIn by viewModel.isLoggedIn.collectAsStateWithLifecycle(false) var name by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") } var picture by remember { mutableStateOf(null) } - val isLoading by viewModel.isCreating.collectAsState() + val scope = rememberCoroutineScope() val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() @@ -256,14 +261,35 @@ fun NewIdentityScreen( Spacer(modifier = Modifier.size(16.dp)) Button( onClick = { - onSave(name, bio, picture) + scope.launch { + try { + val imageBytes = withContext(Dispatchers.IO) { + picture?.let { uri -> + context.contentResolver.openInputStream( + uri + )?.use { input -> input.readBytes() } + } + } + + val contentType = + picture?.let { context.contentResolver.getType(it) } + + // Create the identity + viewModel.createIdentity(name, bio, imageBytes, contentType) + + // Navigate to the home screen if successful + navigator.navigate(Screen.Home) + } catch (e: Exception) { + // Error is handled by viewModel.showError inside createIdentity + } + } }, modifier = Modifier .fillMaxWidth() .height(ButtonDefaults.MediumContainerHeight), - enabled = name.isNotBlank() && !isLoading, + enabled = name.isNotBlank() && !isLoggedIn, ) { - if (isLoading) { + if (isLoggedIn) { LoadingIndicator() } else { Text( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b78eb72..2a78360 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -43,8 +43,8 @@ class NostrViewModel( private val _signerRequired = MutableStateFlow(null) val signerRequired = _signerRequired.asStateFlow() - private val _isCreating = MutableStateFlow(false) - val isCreating = _isCreating.asStateFlow() + private val _isLoggedIn = MutableStateFlow(false) + val isLoggedIn = _isLoggedIn.asStateFlow() private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() @@ -61,7 +61,7 @@ class NostrViewModel( private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() - private val _sentReports = MutableStateFlow>>(emptyMap()) + private val _sentReports = MutableSharedFlow>>() val sentReport = _sentReports.asSharedFlow() private val _errorEvents = Channel(Channel.BUFFERED) @@ -101,7 +101,6 @@ class NostrViewModel( private fun showError(message: String) { viewModelScope.launch { _errorEvents.send(message) - if (isCreating.value) _isCreating.value = false } } @@ -324,56 +323,54 @@ class NostrViewModel( } } - fun createIdentity( + suspend fun createIdentity( name: String, bio: String?, picture: ByteArray?, contentType: String? = null ) { - viewModelScope.launch { - try { - val keys = Keys.generate() - val secret = keys.secretKey().toBech32() - var avatarUrl = "" + _isLoggedIn.value = true + try { + val keys = Keys.generate() + val secret = keys.secretKey().toBech32() + var avatarUrl = "" - // Set loading state - _isCreating.value = true - - // Upload picture to Blossom - if (picture != null) { - val blossom = BlossomClient( - url = "https://blossom.band", - client = HttpClient { - install(ContentNegotiation) { - json(Json { - ignoreUnknownKeys = true - prettyPrint = true - isLenient = true - }) - } + // Upload picture to Blossom + if (picture != null) { + val blossom = BlossomClient( + url = "https://blossom.band", + client = HttpClient { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + prettyPrint = true + isLenient = true + }) } - ) + } + ) - val descriptor = blossom.upload( - file = picture, - contentType = contentType, - signer = keys - ) + val descriptor = blossom.upload( + file = picture, + contentType = contentType, + signer = keys + ) - avatarUrl = descriptor?.url ?: "" - } - - // Create identity - nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) - - // Save secret to the secret storage - secretStore.set("user_signer", secret) - - // Set an empty secret state - _signerRequired.value = false - } catch (e: Exception) { - showError("Error: ${e.message}") + avatarUrl = descriptor?.url ?: "" } + + // Create identity + nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl) + + // Save secret to the secret storage + secretStore.set("user_signer", secret) + + // Set an empty secret state + _signerRequired.value = false + } catch (e: Exception) { + showError("Error: ${e.message}") + } finally { + _isLoggedIn.value = true } } @@ -387,17 +384,17 @@ class NostrViewModel( }.getOrNull() } - fun importIdentity(secret: String) { - viewModelScope.launch { - runCatching { - val signer = createSigner(secret) - nostr.setSigner(signer) - secretStore.set("user_signer", secret) - }.onSuccess { - _signerRequired.value = false - }.onFailure { e -> - showError(e.message ?: "Invalid Secret or Bunker URI") - } + suspend fun importIdentity(secret: String) { + _isLoggedIn.value = true + try { + val signer = createSigner(secret) + nostr.setSigner(signer) + secretStore.set("user_signer", secret) + } catch (e: Exception) { + showError("Error: ${e.message}") + } finally { + _signerRequired.value = false + _isLoggedIn.value = true } } -- 2.49.1 From 9629162d0cf9489cb2512d96190680fc4e0ad76d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 1 Jun 2026 20:45:37 +0700 Subject: [PATCH 2/2] . --- .../kotlin/su/reya/coop/screens/ImportScreen.kt | 9 +++++---- .../kotlin/su/reya/coop/screens/NewIdentityScreen.kt | 6 ++++-- .../commonMain/kotlin/su/reya/coop/NostrViewModel.kt | 11 +++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt index c2d4af5..39ad654 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ImportScreen.kt @@ -90,7 +90,7 @@ fun ImportScreen() { val profile = metadata?.asRecord() val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown" val picture = profile?.picture - + LaunchedEffect(qrScanResult.content) { qrScanResult.content?.let { result -> runCatching { @@ -205,6 +205,7 @@ fun ImportScreen() { BasicTextField( value = secret, onValueChange = { secret = it }, + enabled = !isLoggedIn, modifier = Modifier.fillMaxWidth(), maxLines = 4, keyboardOptions = KeyboardOptions( @@ -217,10 +218,10 @@ fun ImportScreen() { ), visualTransformation = PasswordVisualTransformation('*'), textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy( - color = MaterialTheme.colorScheme.primaryFixed, + color = MaterialTheme.colorScheme.tertiaryFixedDim, fontWeight = FontWeight.SemiBold, ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary), + cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer), decorationBox = { innerTextField -> Box(contentAlignment = Alignment.CenterStart) { if (secret.isEmpty()) { @@ -263,7 +264,7 @@ fun ImportScreen() { LoadingIndicator() } else { Text( - text = if (pubkey == null) "Verify" else "Continue", + text = if (pubkey == null) "Verify" else "Click again to Continue", style = MaterialTheme.typography.titleMediumEmphasized, ) } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index 71d6e5a..ea3139c 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -183,6 +183,7 @@ fun NewIdentityScreen() { BasicTextField( value = name, onValueChange = { name = it }, + enabled = !isLoggedIn, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions( @@ -194,10 +195,10 @@ fun NewIdentityScreen() { } ), textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy( - color = MaterialTheme.colorScheme.primaryFixed, + color = MaterialTheme.colorScheme.tertiaryFixedDim, fontWeight = FontWeight.SemiBold, ), - cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary), + cursorBrush = SolidColor(MaterialTheme.colorScheme.tertiaryContainer), decorationBox = { innerTextField -> Box(contentAlignment = Alignment.CenterStart) { if (name.isEmpty()) { @@ -225,6 +226,7 @@ fun NewIdentityScreen() { BasicTextField( value = bio, onValueChange = { bio = it }, + enabled = !isLoggedIn, modifier = Modifier.fillMaxWidth(), maxLines = 3, keyboardOptions = KeyboardOptions( diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index 2a78360..91d669b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -375,13 +375,16 @@ class NostrViewModel( } suspend fun verifyIdentity(secret: String): PublicKey? { - return runCatching { + try { val signer = createSigner(secret) if (secret.startsWith("bunker://")) { showError("Please approve the connection.") } - signer.getPublicKeyAsync() - }.getOrNull() + return signer.getPublicKeyAsync() + } catch (e: Exception) { + showError("Error: ${e.message}") + return null + } } suspend fun importIdentity(secret: String) { @@ -394,7 +397,7 @@ class NostrViewModel( showError("Error: ${e.message}") } finally { _signerRequired.value = false - _isLoggedIn.value = true + _isLoggedIn.value = false } } -- 2.49.1