From 8b5a8b0e48360cb0cccd0ccec74e5b727f0c6dca Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 6 May 2026 14:25:04 +0700 Subject: [PATCH] improve error handling --- .../androidMain/kotlin/su/reya/coop/App.kt | 29 +++- .../su/reya/coop/screens/NewIdentityScreen.kt | 137 ++++++++++-------- .../kotlin/su/reya/coop/NostrViewModel.kt | 91 ++++++++---- 3 files changed, 156 insertions(+), 101 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 672f1ed..e0af954 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -3,6 +3,7 @@ package su.reya.coop import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -32,6 +33,10 @@ val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") } +val LocalSnackbarHostState = staticCompositionLocalOf { + error("No SnackbarHostState provided") +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { @@ -53,24 +58,32 @@ fun App(dbPath: String) { else -> expressiveLightColorScheme() } + // Snackbar + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { viewModel.initAndConnect(dbPath) + viewModel.startNotificationHandler() viewModel.getChatRooms() + viewModel.errorEvents.collect { message -> + snackbarHostState.showSnackbar(message) + } } MaterialExpressiveTheme( colorScheme = colorScheme, ) { - CompositionLocalProvider(LocalNostrViewModel provides viewModel) { + CompositionLocalProvider( + LocalNostrViewModel provides viewModel, + LocalSnackbarHostState provides snackbarHostState, + ) { rememberCoroutineScope() val navController = rememberNavController() - val hasSecret by viewModel.hasSecret.collectAsState(initial = null) + val emptySecret by viewModel.emptySecret.collectAsState(initial = null) - LaunchedEffect(hasSecret) { + LaunchedEffect(emptySecret) { // Navigate to the home screen if the secret is already set - if (hasSecret == true) { - // Start a background notification handler - viewModel.startNotificationHandler() + if (emptySecret == false) { // Get chat rooms viewModel.getChatRooms() // Navigate to the home screen @@ -81,11 +94,11 @@ fun App(dbPath: String) { } // Show loading screen while initializing - if (hasSecret == null) return@CompositionLocalProvider + if (emptySecret == null) return@CompositionLocalProvider NavHost( navController = navController, - startDestination = if (hasSecret == true) Screen.Home else Screen.Onboarding + startDestination = if (emptySecret == false) Screen.Home else Screen.Onboarding ) { composable { backStackEntry -> OnboardingScreen( 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 4486217..a7ee9a4 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -20,6 +20,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -33,6 +35,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import su.reya.coop.LocalSnackbarHostState @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable @@ -40,6 +43,7 @@ fun NewIdentityScreen( isLoading: Boolean, onSave: (name: String, bio: String, picture: Uri?) -> Unit ) { + val snackbarHostState = LocalSnackbarHostState.current var name by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") } var picture by remember { mutableStateOf(null) } @@ -49,70 +53,79 @@ fun NewIdentityScreen( 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() - + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + content = { innerPadding -> + Surface( + modifier = Modifier + .fillMaxSize() + .padding(top = innerPadding.calculateTopPadding()), + color = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - // + 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") + } + } } } } - 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/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b4f3532..c6cca12 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.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -23,8 +24,8 @@ class NostrViewModel( private val nostr: Nostr, private val secretStore: SecretStorage ) : ViewModel() { - private val _hasSecret = MutableStateFlow(null) - val hasSecret = _hasSecret.asStateFlow() + private val _emptySecret = MutableStateFlow(null) + val emptySecret = _emptySecret.asStateFlow() private val _isCreating = MutableStateFlow(false) val isCreating = _isCreating.asStateFlow() @@ -32,6 +33,9 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() + private val _errorEvents = Channel(Channel.BUFFERED) + val errorEvents = _errorEvents.receiveAsFlow() + private val _metadataStore = mutableMapOf>() private val metadataRequestChannel = Channel(Channel.UNLIMITED) private val seenPublicKeys = mutableSetOf() @@ -40,6 +44,12 @@ class NostrViewModel( startMetadataBatchProcessor() } + private fun showError(message: String) { + viewModelScope.launch { + _errorEvents.send(message) + } + } + private fun startMetadataBatchProcessor() { viewModelScope.launch { val batch = mutableSetOf() @@ -91,11 +101,7 @@ class NostrViewModel( } fun getUserProfile(): StateFlow { - return try { - getMetadata(nostr.userPubkey!!) - } catch (e: Exception) { - MutableStateFlow(null) - } + return getMetadata(nostr.userPubkey!!) } fun initAndConnect(dbPath: String) { @@ -108,7 +114,7 @@ class NostrViewModel( // Get user's secret getUserSecret() } catch (e: Exception) { - println("Failed to connect: ${e.message}") + showError("Failed to initialize Nostr: ${e.message}") } } } @@ -121,31 +127,43 @@ class NostrViewModel( } } + fun logout() { + viewModelScope.launch { + _emptySecret.value = true + _chatRooms.value = emptySet() + secretStore.clear("user_signer") + nostr.exit() + } + } + suspend fun getUserSecret() { // Get user's signer secret val secret = secretStore.get("user_signer") // If no secret is found, show onboarding screen - if (secret == null) { - _hasSecret.value = false - return + when (secret) { + null -> { + _emptySecret.value = true + return + } + + else -> _emptySecret.value = false } - _hasSecret.value = true // Handle different signer types if (secret.startsWith("nsec1")) { val keys = Keys.parse(secret) nostr.setKeySigner(keys) } else if (secret.startsWith("bunker://")) { - val appKeys = getOrInitAppKeys() - val bunker = NostrConnectUri.parse(secret) - val remote = NostrConnect( - uri = bunker, - appKeys = appKeys, - timeout = Duration.parse("5"), - opts = null - ) - nostr.setRemoteSigner(remote) + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50") // 50 seconds timeout + val remote = NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setRemoteSigner(remote) + } catch (e: Exception) { + showError("Error: ${e.message}") + } } else { throw IllegalArgumentException("Invalid secret format: $secret") } @@ -178,21 +196,32 @@ class NostrViewModel( // Save secret to the secret storage secretStore.set("user_signer", secret) } catch (e: Exception) { - println("Create identity failed: $e") + showError("Error: ${e.message}") } } } fun importIdentity(secret: String) { - // TODO: Implement import - } - - fun logout() { viewModelScope.launch { - _hasSecret.value = false - _chatRooms.value = emptySet() - secretStore.clear("user_signer") - nostr.exit() + if (secret.startsWith("nsec1")) { + val keys = Keys.parse(secret) + nostr.setKeySigner(keys) + secretStore.set("user_signer", secret) + } else if (secret.startsWith("bunker://")) { + try { + val appKeys = getOrInitAppKeys() + val bunker = NostrConnectUri.parse(secret) + val timeout = Duration.parse("50") // 50 seconds timeout + val remote = + NostrConnect(uri = bunker, appKeys = appKeys, timeout = timeout, null) + nostr.setRemoteSigner(remote) + secretStore.set("user_signer", secret) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } else { + showError("Please enter a valid Secret or Bunker URI.") + } } } @@ -201,7 +230,7 @@ class NostrViewModel( try { _chatRooms.value = nostr.getChatRooms() ?: emptySet() } catch (e: Exception) { - println("Failed to get chat rooms: ${e.message}") + showError("Error: ${e.message}") } } }