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