Decouple error message display from AccountStateViewModel

Extract toast/error infrastructure into a standalone ToastManager singleton
so composables and background services no longer need to reference
AccountStateViewModel just to show error dialogs.

- Add ToastManager.kt with ToastMsg sealed class hierarchy and MutableSharedFlow
- Remove toast methods and ToastMsg types from AccountStateViewModel
- Update DisplayErrorMessages() to take no parameter and collect from ToastManager
- Replace AmberListenerSingleton.accountStateViewModel field with direct ToastManager calls
- Update Amber.kt and BunkerRequestUtils.kt to use ToastManager directly
- Remove accountStateViewModel parameter from composables that only used it for toasts:
  NewApplicationScreen, ConfirmPinScreen, FeedbackScreen, IntentSingleEventHomeScreen,
  NewNsecBunkerScreen, EditConfigurationScreen, DefaultProfileRelaysScreen, EditRelaysDialog,
  IncomingRequestScreen, and update MainScreen callers accordingly

https://claude.ai/code/session_01Jk3XjYxVsPZrT88aH6c6cP
This commit is contained in:
Claude
2026-03-01 08:56:34 +00:00
parent 17a257349d
commit 80b3069e45
16 changed files with 95 additions and 114 deletions
@@ -33,6 +33,7 @@ import com.greenart7c3.nostrsigner.service.NotificationSubscription
import com.greenart7c3.nostrsigner.service.ProfileSubscription
import com.greenart7c3.nostrsigner.service.crashreports.CrashReportCache
import com.greenart7c3.nostrsigner.service.crashreports.UnexpectedCrashSaver
import com.greenart7c3.nostrsigner.ui.ToastManager
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
@@ -134,9 +135,9 @@ class Amber :
return true
} catch (e: Exception) {
if (e.message?.contains("EACCES (Permission denied)") == true) {
AmberListenerSingleton.accountStateViewModel?.toast(getString(R.string.warning), getString(R.string.network_permission_message))
ToastManager.toast(getString(R.string.warning), getString(R.string.network_permission_message))
} else if (e.message?.contains("socket failed: EPERM (Operation not permitted)") == true) {
AmberListenerSingleton.accountStateViewModel?.toast(getString(R.string.warning), getString(R.string.network_permission_message))
ToastManager.toast(getString(R.string.warning), getString(R.string.network_permission_message))
}
Log.e(TAG, "Failed to connect to proxy", e)
return false
@@ -7,7 +7,7 @@ import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.database.LogEntity
import com.greenart7c3.nostrsigner.service.NotificationUtils.sendErrorNotification
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
import com.greenart7c3.nostrsigner.ui.ToastManager
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
@@ -16,16 +16,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@SuppressLint("StaticFieldLeak")
object AmberListenerSingleton {
var accountStateViewModel: AccountStateViewModel? = null
val latestErrorMessages = mutableListOf<String>()
fun showErrorMessage() {
if (latestErrorMessages.isEmpty()) return
if (latestErrorMessages.last().isBlank()) return
if (Amber.isAppInForeground) {
accountStateViewModel?.toast("Error", latestErrorMessages.last())
ToastManager.toast("Error", latestErrorMessages.last())
} else {
val notificationManager: NotificationManager =
Amber.instance.applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -17,6 +17,7 @@ import com.greenart7c3.nostrsigner.models.SignerType
import com.greenart7c3.nostrsigner.relays.AmberListenerSingleton
import com.greenart7c3.nostrsigner.service.model.AmberEvent
import com.greenart7c3.nostrsigner.ui.RememberType
import com.greenart7c3.nostrsigner.ui.ToastManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse
@@ -77,7 +78,7 @@ object BunkerRequestUtils {
if (Amber.instance.settings.useProxy) {
val isProxyWorking = Amber.instance.isSocksProxyAlive("127.0.0.1", Amber.instance.settings.proxyPort)
if (!isProxyWorking) {
AmberListenerSingleton.accountStateViewModel?.toast(context.getString(R.string.warning), context.getString(R.string.failed_to_connect_to_tor_orbot))
ToastManager.toast(context.getString(R.string.warning), context.getString(R.string.failed_to_connect_to_tor_orbot))
onDone(false)
onLoading(false)
return
@@ -36,7 +36,6 @@ import com.greenart7c3.nostrsigner.MainViewModel
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.models.AmberBunkerRequest
import com.greenart7c3.nostrsigner.models.IntentResultType
import com.greenart7c3.nostrsigner.relays.AmberListenerSingleton
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.ui.navigation.Route
@@ -89,9 +88,7 @@ fun AccountScreen(
} ?: state.route,
)
AmberListenerSingleton.accountStateViewModel = accountStateViewModel
DisplayErrorMessages(accountStateViewModel)
DisplayErrorMessages()
MainScreen(
account = state.account,
accountStateViewModel = accountStateViewModel,
@@ -121,8 +118,8 @@ fun AccountScreen(
}
@Composable
private fun DisplayErrorMessages(accountViewModel: AccountStateViewModel) {
val openDialogMsg = accountViewModel.toasts.collectAsState(null)
private fun DisplayErrorMessages() {
val openDialogMsg = ToastManager.toasts.collectAsState(null)
openDialogMsg.value?.let { obj ->
when (obj) {
@@ -134,7 +131,7 @@ private fun DisplayErrorMessages(accountViewModel: AccountStateViewModel) {
title,
content,
) {
accountViewModel.clearToasts()
ToastManager.clearToasts()
}
} else {
val title = stringResource(obj.titleResId)
@@ -143,7 +140,7 @@ private fun DisplayErrorMessages(accountViewModel: AccountStateViewModel) {
title,
content,
) {
accountViewModel.clearToasts()
ToastManager.clearToasts()
}
}
@@ -152,7 +149,7 @@ private fun DisplayErrorMessages(accountViewModel: AccountStateViewModel) {
obj.title,
obj.msg,
) {
accountViewModel.clearToasts()
ToastManager.clearToasts()
}
is ConfirmationToastMsg ->
InformationDialog(
@@ -160,7 +157,7 @@ private fun DisplayErrorMessages(accountViewModel: AccountStateViewModel) {
obj.msg,
) {
obj.onOk()
accountViewModel.clearToasts()
ToastManager.clearToasts()
}
is AcceptRejectToastMsg ->
InformationDialog(
@@ -168,11 +165,11 @@ private fun DisplayErrorMessages(accountViewModel: AccountStateViewModel) {
obj.msg,
onAccept = {
obj.onAccept()
accountViewModel.clearToasts()
ToastManager.clearToasts()
},
onReject = {
obj.onReject()
accountViewModel.clearToasts()
ToastManager.clearToasts()
},
)
}
@@ -1,10 +1,8 @@
package com.greenart7c3.nostrsigner.ui
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.greenart7c3.nostrsigner.Amber
import com.greenart7c3.nostrsigner.LocalPreferences
import com.greenart7c3.nostrsigner.models.Account
@@ -18,71 +16,21 @@ import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import com.vitorpamplona.quartz.utils.Hex
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@Immutable
open class ToastMsg
@Immutable
class StringToastMsg(val title: String, val msg: String) : ToastMsg()
@Immutable
class ConfirmationToastMsg(val title: String, val msg: String, val onOk: () -> Unit) : ToastMsg()
@Immutable
class AcceptRejectToastMsg(val title: String, val msg: String, val onAccept: () -> Unit, val onReject: () -> Unit) : ToastMsg()
@Immutable
class ResourceToastMsg(
val titleResId: Int,
val resourceId: Int,
val params: Array<out String>? = null,
) : ToastMsg()
@Stable
class AccountStateViewModel(npub: String?) : ViewModel() {
private val _accountContent = MutableStateFlow<AccountState>(AccountState.LoggedOff)
val accountContent = _accountContent.asStateFlow()
val toasts = MutableSharedFlow<ToastMsg?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private var observerJob: Job? = null
init {
tryLoginExistingAccount(null, npub)
}
fun clearToasts() {
viewModelScope.launch { toasts.emit(null) }
}
fun toast(
title: String,
message: String,
) {
viewModelScope.launch { toasts.emit(StringToastMsg(title, message)) }
}
fun toast(
title: String,
message: String,
onOk: () -> Unit,
) {
viewModelScope.launch { toasts.emit(ConfirmationToastMsg(title, message, onOk)) }
}
fun toast(
title: String,
message: String,
onAccept: () -> Unit,
onReject: () -> Unit,
) {
viewModelScope.launch { toasts.emit(AcceptRejectToastMsg(title, message, onAccept, onReject)) }
}
private fun tryLoginExistingAccount(
route: String?,
npub: String?,
@@ -40,7 +40,6 @@ import kotlinx.coroutines.launch
@Composable
fun DefaultProfileRelaysScreen(
modifier: Modifier,
accountStateViewModel: AccountStateViewModel,
account: Account,
) {
val scope = rememberCoroutineScope()
@@ -116,7 +115,6 @@ fun DefaultProfileRelaysScreen(
isLoading,
relays2,
scope,
accountStateViewModel,
account,
context,
shouldCheckForBunker = false,
@@ -153,7 +151,6 @@ fun DefaultProfileRelaysScreen(
isLoading,
relays2,
scope,
accountStateViewModel,
account,
context,
shouldCheckForBunker = false,
@@ -68,7 +68,6 @@ import kotlinx.coroutines.launch
fun EditConfigurationScreen(
modifier: Modifier = Modifier,
key: String,
accountStateViewModel: AccountStateViewModel,
account: Account,
navController: NavController,
) {
@@ -188,7 +187,6 @@ fun EditConfigurationScreen(
isLoading,
relays,
scope,
accountStateViewModel,
account,
context,
onDone = {},
@@ -211,7 +209,6 @@ fun EditConfigurationScreen(
isLoading,
relays,
scope,
accountStateViewModel,
account,
context,
onDone = {},
@@ -46,7 +46,6 @@ import kotlinx.coroutines.launch
fun FeedbackScreen(
modifier: Modifier = Modifier,
account: Account,
accountStateViewModel: AccountStateViewModel,
onDismiss: () -> Unit,
onLoading: (Boolean) -> Unit,
) {
@@ -152,14 +151,14 @@ fun FeedbackScreen(
account,
)
if (result) {
accountStateViewModel.toast(
ToastManager.toast(
Amber.instance.getString(R.string.warning),
Amber.instance.getString(R.string.feedback_sent),
)
onLoading(false)
onDismiss()
} else {
accountStateViewModel.toast(
ToastManager.toast(
Amber.instance.getString(R.string.warning),
Amber.instance.getString(R.string.failed_to_send_event),
)
@@ -36,7 +36,6 @@ fun IncomingRequestScreen(
packageName: String?,
applicationName: String?,
account: Account,
accountStateViewModel: AccountStateViewModel,
navController: NavController,
onRemoveIntentData: (List<IntentData>, IntentResultType) -> Unit,
onLoading: (Boolean) -> Unit,
@@ -70,7 +69,6 @@ fun IncomingRequestScreen(
applicationName = applicationName,
intentData = intents.first(),
account = account,
accountStateViewModel = accountStateViewModel,
onRemoveIntentData = onRemoveIntentData,
onLoading = onLoading,
)
@@ -381,7 +381,6 @@ fun MainScreen(
packageName = packageName,
applicationName = appName,
account = account,
accountStateViewModel = accountStateViewModel,
navController = navController,
onRemoveIntentData = onRemoveIntentData,
onLoading = {
@@ -563,7 +562,6 @@ fun MainScreen(
.padding(padding)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f),
accountStateViewModel = accountStateViewModel,
)
},
)
@@ -608,7 +606,6 @@ fun MainScreen(
val scrollState = rememberScrollState()
NewApplicationScreen(
account = account,
accountStateViewModel = accountStateViewModel,
navController = navController,
modifier =
Modifier
@@ -628,7 +625,6 @@ fun MainScreen(
val scrollState = rememberScrollState()
NewNsecBunkerScreen(
account = account,
accountStateViewModel = accountStateViewModel,
navController = navController,
modifier =
Modifier
@@ -746,7 +742,6 @@ fun MainScreen(
.padding(top = verticalPadding * 1.5f)
.imePadding(),
key = key,
accountStateViewModel = accountStateViewModel,
account = account,
navController = navController,
)
@@ -779,7 +774,6 @@ fun MainScreen(
.padding(padding)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f),
accountStateViewModel = accountStateViewModel,
pin = pin,
navController = navController,
)
@@ -832,7 +826,6 @@ fun MainScreen(
.verticalScroll(scrollState)
.padding(horizontal = verticalPadding)
.padding(top = verticalPadding * 1.5f),
accountStateViewModel = accountStateViewModel,
account = account,
)
},
@@ -908,7 +901,6 @@ fun MainScreen(
.padding(top = verticalPadding * 1.5f)
.imePadding(),
account = account,
accountStateViewModel = accountStateViewModel,
onDismiss = {
Amber.instance.applicationIOScope.launch(Dispatchers.Main) {
navController.navigateUp()
@@ -34,7 +34,6 @@ import kotlinx.coroutines.launch
@Composable
fun NewApplicationScreen(
modifier: Modifier,
accountStateViewModel: AccountStateViewModel,
account: Account,
navController: NavController,
) {
@@ -49,7 +48,7 @@ fun NewApplicationScreen(
dialogOpen.value = false
if (it.isNullOrBlank()) {
accountStateViewModel.toast(
ToastManager.toast(
title,
message,
)
@@ -57,7 +56,7 @@ fun NewApplicationScreen(
}
if (!it.startsWith("nostrconnect://")) {
accountStateViewModel.toast(
ToastManager.toast(
title,
message,
)
@@ -90,7 +89,7 @@ fun NewApplicationScreen(
scope.launch {
val clipboardText = clipboardManager.getClipEntry()?.clipData?.getItemAt(0)
if (clipboardText == null) {
accountStateViewModel.toast(
ToastManager.toast(
title,
message,
)
@@ -98,14 +97,14 @@ fun NewApplicationScreen(
}
if (clipboardText.text.isBlank()) {
accountStateViewModel.toast(
ToastManager.toast(
title,
message,
)
return@launch
}
if (!clipboardText.text.startsWith("nostrconnect://")) {
accountStateViewModel.toast(
ToastManager.toast(
title,
message,
)
@@ -63,7 +63,6 @@ import kotlinx.coroutines.launch
fun NewNsecBunkerScreen(
modifier: Modifier = Modifier,
account: Account,
accountStateViewModel: AccountStateViewModel,
navController: NavController,
) {
val secret = remember { mutableStateOf(UUID.randomUUID().toString()) }
@@ -163,7 +162,6 @@ fun NewNsecBunkerScreen(
isLoading,
relays,
scope,
accountStateViewModel,
account = account,
context,
onDone = {
@@ -187,7 +185,6 @@ fun NewNsecBunkerScreen(
isLoading,
relays,
scope,
accountStateViewModel,
account = account,
context,
onDone = {
@@ -226,7 +223,7 @@ fun NewNsecBunkerScreen(
text = stringResource(R.string.create),
onClick = {
if (relays.isEmpty()) {
accountStateViewModel.toast(
ToastManager.toast(
title,
noRelaysMessage,
)
@@ -235,7 +232,7 @@ fun NewNsecBunkerScreen(
}
if (name.text.isBlank()) {
accountStateViewModel.toast(
ToastManager.toast(
title,
noNameMessage,
)
@@ -28,7 +28,6 @@ fun SetupPinScreen(
@Composable
fun ConfirmPinScreen(
modifier: Modifier = Modifier,
accountStateViewModel: AccountStateViewModel,
pin: String,
navController: NavController,
) {
@@ -39,7 +38,7 @@ fun ConfirmPinScreen(
RandomPinInput(
onPinEntered = { enteredPin ->
if (enteredPin != pin) {
accountStateViewModel.toast(context.getString(R.string.pin), context.getString(R.string.pin_does_not_match))
ToastManager.toast(context.getString(R.string.pin), context.getString(R.string.pin_does_not_match))
} else {
val usePin = Amber.instance.settings.usePin
if (usePin) {
@@ -0,0 +1,63 @@
package com.greenart7c3.nostrsigner.ui
import androidx.compose.runtime.Immutable
import com.greenart7c3.nostrsigner.Amber
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
@Immutable
open class ToastMsg
@Immutable
class StringToastMsg(val title: String, val msg: String) : ToastMsg()
@Immutable
class ConfirmationToastMsg(val title: String, val msg: String, val onOk: () -> Unit) : ToastMsg()
@Immutable
class AcceptRejectToastMsg(
val title: String,
val msg: String,
val onAccept: () -> Unit,
val onReject: () -> Unit,
) : ToastMsg()
@Immutable
class ResourceToastMsg(
val titleResId: Int,
val resourceId: Int,
val params: Array<out String>? = null,
) : ToastMsg()
object ToastManager {
val toasts = MutableSharedFlow<ToastMsg?>(0, 3, onBufferOverflow = BufferOverflow.DROP_OLDEST)
fun clearToasts() {
Amber.instance.applicationIOScope.launch { toasts.emit(null) }
}
fun toast(
title: String,
message: String,
) {
Amber.instance.applicationIOScope.launch { toasts.emit(StringToastMsg(title, message)) }
}
fun toast(
title: String,
message: String,
onOk: () -> Unit,
) {
Amber.instance.applicationIOScope.launch { toasts.emit(ConfirmationToastMsg(title, message, onOk)) }
}
fun toast(
title: String,
message: String,
onAccept: () -> Unit,
onReject: () -> Unit,
) {
Amber.instance.applicationIOScope.launch { toasts.emit(AcceptRejectToastMsg(title, message, onAccept, onReject)) }
}
}
@@ -57,8 +57,8 @@ import com.greenart7c3.nostrsigner.models.TimeUtils.formatLongToCustomDateTimeWi
import com.greenart7c3.nostrsigner.models.defaultAppRelays
import com.greenart7c3.nostrsigner.relays.AmberListenerSingleton
import com.greenart7c3.nostrsigner.service.TrustScoreService
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
import com.greenart7c3.nostrsigner.ui.CenterCircularProgressIndicator
import com.greenart7c3.nostrsigner.ui.ToastManager
import com.greenart7c3.nostrsigner.ui.RelayCard
import com.greenart7c3.nostrsigner.ui.components.AmberButton
import com.greenart7c3.nostrsigner.ui.components.TrustScoreBadge
@@ -92,7 +92,6 @@ import kotlinx.coroutines.withTimeoutOrNull
@Composable
fun DefaultRelaysScreen(
modifier: Modifier,
accountStateViewModel: AccountStateViewModel,
account: Account,
) {
val scope = rememberCoroutineScope()
@@ -187,7 +186,6 @@ fun DefaultRelaysScreen(
isLoading,
relays2,
scope,
accountStateViewModel,
account,
context,
onDone = {
@@ -223,7 +221,6 @@ fun DefaultRelaysScreen(
isLoading,
relays2,
scope,
accountStateViewModel,
account,
context,
onDone = {
@@ -288,7 +285,6 @@ fun onAddRelay(
isLoading: MutableState<Boolean>,
relays2: SnapshotStateList<NormalizedRelayUrl>,
scope: CoroutineScope,
accountStateViewModel: AccountStateViewModel,
account: Account,
context: Context,
shouldCheckForBunker: Boolean = true,
@@ -373,7 +369,7 @@ fun onAddRelay(
}
override fun onCannotConnect(relay: IRelayClient, errorMessage: String) {
accountStateViewModel.toast(
ToastManager.toast(
context.getString(R.string.relay),
context.getString(R.string.could_not_connect_to_relay),
onAccept = {
@@ -419,7 +415,7 @@ fun onAddRelay(
}
if (canContinue == null) {
accountStateViewModel.toast(
ToastManager.toast(
context.getString(R.string.relay),
context.getString(R.string.could_not_connect_to_relay),
onAccept = {
@@ -472,7 +468,7 @@ fun onAddRelay(
relays2.add(addedWSS)
onDone()
} else if (!filterResult) {
accountStateViewModel.toast(
ToastManager.toast(
context.getString(R.string.relay),
context.getString(R.string.relay_filter_failed),
onAccept = {
@@ -31,7 +31,7 @@ import com.greenart7c3.nostrsigner.models.kindToNip
import com.greenart7c3.nostrsigner.service.IntentUtils
import com.greenart7c3.nostrsigner.service.isPrivateEvent
import com.greenart7c3.nostrsigner.service.toShortenHex
import com.greenart7c3.nostrsigner.ui.AccountStateViewModel
import com.greenart7c3.nostrsigner.ui.ToastManager
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
import com.vitorpamplona.quartz.nip19Bech32.toNpub
@@ -47,7 +47,6 @@ fun IntentSingleEventHomeScreen(
applicationName: String?,
intentData: IntentData,
account: Account,
accountStateViewModel: AccountStateViewModel,
onRemoveIntentData: (List<IntentData>, IntentResultType) -> Unit,
onLoading: (Boolean) -> Unit,
) {
@@ -260,7 +259,7 @@ fun IntentSingleEventHomeScreen(
account = account,
onAccept = {
if (intentData.unsignedEventKey.isNotBlank() && intentData.unsignedEventKey != account.hexKey && !isPrivateEvent(event.kind, event.tags)) {
accountStateViewModel.toast(
ToastManager.toast(
title = context.getString(R.string.warning),
message = context.getString(R.string.event_pubkey_is_not_equal_to_current_logged_in_user),
)