fix: app doesn't navigate to home screen after create or import identity #9

Merged
reya merged 2 commits from fix-navigation-issues into master 2026-06-01 13:47:17 +00:00
4 changed files with 123 additions and 109 deletions

View File

@@ -189,25 +189,10 @@ fun App(viewModel: NostrViewModel) {
OnboardingScreen()
}
entry<Screen.Import> {
ImportScreen(
onSave = { secret ->
viewModel.importIdentity(secret)
}
)
ImportScreen()
}
entry<Screen.NewIdentity> {
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<Screen.Chat> { key ->
ChatScreen(id = key.id)

View File

@@ -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<PublicKey?>(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 {
@@ -209,6 +205,7 @@ fun ImportScreen(
BasicTextField(
value = secret,
onValueChange = { secret = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
keyboardOptions = KeyboardOptions(
@@ -221,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()) {
@@ -246,24 +243,28 @@ fun ImportScreen(
Spacer(modifier = Modifier.size(16.dp))
Button(
onClick = {
if (pubkey == null) {
scope.launch {
if (pubkey == null) {
viewModel.verifyIdentity(secret).let { pubkey = it }
}
} else {
onSave(secret)
// Import the identity
viewModel.importIdentity(secret)
// Navigate to the home screen
navigator.navigate(Screen.Home)
}
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading,
enabled = secret.isNotBlank() && !isLoggedIn,
) {
if (isLoading) {
if (isLoggedIn) {
LoadingIndicator()
} else {
Text(
text = if (pubkey == null) "Verify" else "Continue",
text = if (pubkey == null) "Verify" else "Click again to Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}

View File

@@ -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<Uri?>(null) }
val isLoading by viewModel.isCreating.collectAsState()
val scope = rememberCoroutineScope()
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
@@ -178,6 +183,7 @@ fun NewIdentityScreen(
BasicTextField(
value = name,
onValueChange = { name = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -189,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()) {
@@ -220,6 +226,7 @@ fun NewIdentityScreen(
BasicTextField(
value = bio,
onValueChange = { bio = it },
enabled = !isLoggedIn,
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
keyboardOptions = KeyboardOptions(
@@ -256,14 +263,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(

View File

@@ -43,8 +43,8 @@ class NostrViewModel(
private val _signerRequired = MutableStateFlow<Boolean?>(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<Set<Room>>(emptySet())
val chatRooms = _chatRooms.asStateFlow()
@@ -61,7 +61,7 @@ class NostrViewModel(
private val _newEvents = MutableSharedFlow<UnsignedEvent>(extraBufferCapacity = 100)
val newEvents = _newEvents.asSharedFlow()
private val _sentReports = MutableStateFlow<Map<EventId, List<RelayUrl>>>(emptyMap())
private val _sentReports = MutableSharedFlow<Map<EventId, List<RelayUrl>>>()
val sentReport = _sentReports.asSharedFlow()
private val _errorEvents = Channel<String>(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,21 +323,18 @@ class NostrViewModel(
}
}
fun createIdentity(
suspend fun createIdentity(
name: String,
bio: String?,
picture: ByteArray?,
contentType: String? = null
) {
viewModelScope.launch {
_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(
@@ -373,31 +369,35 @@ class NostrViewModel(
_signerRequired.value = false
} catch (e: Exception) {
showError("Error: ${e.message}")
}
} finally {
_isLoggedIn.value = true
}
}
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
}
}
fun importIdentity(secret: String) {
viewModelScope.launch {
runCatching {
suspend fun importIdentity(secret: String) {
_isLoggedIn.value = true
try {
val signer = createSigner(secret)
nostr.setSigner(signer)
secretStore.set("user_signer", secret)
}.onSuccess {
} catch (e: Exception) {
showError("Error: ${e.message}")
} finally {
_signerRequired.value = false
}.onFailure { e ->
showError(e.message ?: "Invalid Secret or Bunker URI")
}
_isLoggedIn.value = false
}
}