diff --git a/AGENTS.md b/AGENTS.md index e7a0b0da0..20c73500c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,6 +210,7 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - PREFER to use one-liners with `run {}` when applicable, e.g. `override fun someCall(value: String) = run { this.value = value }` - ALWAYS add imports instead of inline fully-qualified names - PREFER to place `@Suppress()` annotations at the narrowest possible scope +- NEVER use `*Manager` suffix for classes, PREFER narrow-scope constructs that do not tend to grow into unmaintainable god objects ### Architecture Guidelines diff --git a/app/src/main/java/to/bitkit/di/ViewModelModule.kt b/app/src/main/java/to/bitkit/di/ViewModelModule.kt index d0f531515..a7d6da319 100644 --- a/app/src/main/java/to/bitkit/di/ViewModelModule.kt +++ b/app/src/main/java/to/bitkit/di/ViewModelModule.kt @@ -5,8 +5,8 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import to.bitkit.ui.shared.toast.ToastQueueManager +import kotlinx.coroutines.CoroutineDispatcher +import to.bitkit.ui.shared.toast.ToastQueue import javax.inject.Singleton @Module @@ -19,7 +19,7 @@ object ViewModelModule { } @Provides - fun provideToastManagerProvider(): (CoroutineScope) -> ToastQueueManager { - return ::ToastQueueManager + fun provideToastQueueProvider(): (CoroutineDispatcher) -> ToastQueue { + return ::ToastQueue } } diff --git a/app/src/main/java/to/bitkit/models/Toast.kt b/app/src/main/java/to/bitkit/models/Toast.kt index a4dc00d99..debb58267 100644 --- a/app/src/main/java/to/bitkit/models/Toast.kt +++ b/app/src/main/java/to/bitkit/models/Toast.kt @@ -1,16 +1,21 @@ package to.bitkit.models +import androidx.compose.runtime.Stable +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@Stable data class Toast( val type: ToastType, - val title: String, - val description: String? = null, + val title: ToastText, + val body: ToastText? = null, val autoHide: Boolean, - val visibilityTime: Long = VISIBILITY_TIME_DEFAULT, + val duration: Duration = DURATION_DEFAULT, val testTag: String? = null, ) { - enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR } - companion object { - const val VISIBILITY_TIME_DEFAULT = 3000L + val DURATION_DEFAULT: Duration = 3.seconds } } + +enum class ToastType { SUCCESS, INFO, LIGHTNING, WARNING, ERROR } diff --git a/app/src/main/java/to/bitkit/models/ToastText.kt b/app/src/main/java/to/bitkit/models/ToastText.kt new file mode 100644 index 000000000..fb36e8a26 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ToastText.kt @@ -0,0 +1,32 @@ +package to.bitkit.models + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.res.stringResource + +@Stable +sealed interface ToastText { + @JvmInline + value class Resource(@StringRes val resId: Int) : ToastText + + @JvmInline + value class Literal(val value: String) : ToastText + + companion object { + operator fun invoke(value: String): ToastText = Literal(value) + operator fun invoke(@StringRes resId: Int): ToastText = Resource(resId) + } +} + +@Composable +fun ToastText.asString(): String = when (this) { + is ToastText.Resource -> stringResource(resId) + is ToastText.Literal -> value +} + +fun ToastText.asString(context: Context): String = when (this) { + is ToastText.Resource -> context.getString(resId) + is ToastText.Literal -> value +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index 9088a0e88..cb1476ecf 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -42,11 +42,10 @@ import to.bitkit.models.BackupItemStatus import to.bitkit.models.BlocktankBackupV1 import to.bitkit.models.MetadataBackupV1 import to.bitkit.models.SettingsBackupV1 -import to.bitkit.models.Toast import to.bitkit.models.WalletBackupV1 import to.bitkit.models.WidgetsBackupV1 import to.bitkit.services.LightningService -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf import java.util.concurrent.ConcurrentHashMap @@ -86,6 +85,7 @@ class BackupRepo @Inject constructor( private val lightningService: LightningService, private val clock: Clock, private val db: AppDb, + private val toaster: Toaster, ) { private val scope = CoroutineScope(ioDispatcher + SupervisorJob()) @@ -373,10 +373,9 @@ class BackupRepo @Inject constructor( lastNotificationTime = currentTime scope.launch { - ToastEventBus.send( - type = Toast.ToastType.ERROR, + toaster.error( title = context.getString(R.string.settings__backup__failed_title), - description = context.getString(R.string.settings__backup__failed_message).formatPlural( + body = context.getString(R.string.settings__backup__failed_message).formatPlural( mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS)) ), ) diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 57d347abc..68db7f2c2 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -29,11 +29,10 @@ import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay import to.bitkit.models.SATS_IN_BTC import to.bitkit.models.STUB_RATE -import to.bitkit.models.Toast import to.bitkit.models.asBtc import to.bitkit.models.formatCurrency import to.bitkit.services.CurrencyService -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import java.math.BigDecimal import java.math.RoundingMode @@ -53,6 +52,7 @@ class CurrencyRepo @Inject constructor( private val cacheStore: CacheStore, private val clock: Clock, @Named("enablePolling") private val enablePolling: Boolean, + private val toaster: Toaster, ) : AmountInputHandler { private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob()) private val _currencyState = MutableStateFlow(CurrencyState()) @@ -92,10 +92,9 @@ class CurrencyRepo @Inject constructor( .distinctUntilChanged() .collect { isStale -> if (isStale) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, + toaster.error( title = "Rates currently unavailable", - description = "An error has occurred. Please try again later." + body = "An error has occurred. Please try again later." ) } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 96188dbf9..43566bd51 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -46,7 +46,6 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import to.bitkit.env.Env import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.Toast import to.bitkit.models.WidgetType import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen @@ -360,6 +359,7 @@ fun ContentView( LocalTransferViewModel provides transferViewModel, LocalSettingsViewModel provides settingsViewModel, LocalBackupsViewModel provides backupsViewModel, + LocalToaster provides appViewModel.toaster, LocalDrawerState provides drawerState, LocalBalances provides balance, LocalCurrencies provides currencies, @@ -625,12 +625,11 @@ private fun RootNavHost( viewModel = transferViewModel, onBackClick = { navController.popBackStack() }, onOrderCreated = { navController.navigate(Routes.SpendingConfirm) }, - toastException = { appViewModel.toast(it) }, - toast = { title, description -> - appViewModel.toast( - type = Toast.ToastType.ERROR, + toastException = { appViewModel.toaster.error(it) }, + toast = { title, body -> + appViewModel.toaster.error( title = title, - description = description + body = body ) }, ) diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 2843e38c4..e381b78b1 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf import to.bitkit.models.BalanceState import to.bitkit.repositories.CurrencyState +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.BackupsViewModel @@ -29,6 +30,7 @@ val LocalActivityListViewModel = staticCompositionLocalOf { null } val LocalSettingsViewModel = staticCompositionLocalOf { null } val LocalBackupsViewModel = staticCompositionLocalOf { null } +val LocalToaster = staticCompositionLocalOf { error("Toaster not provided") } val appViewModel: AppViewModel? @Composable get() = LocalAppViewModel.current @@ -56,3 +58,6 @@ val backupsViewModel: BackupsViewModel? val drawerState: DrawerState? @Composable get() = LocalDrawerState.current + +val toaster: Toaster + @Composable get() = LocalToaster.current diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index db4b23dee..9fc9b9030 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -36,7 +36,7 @@ import to.bitkit.androidServices.LightningNodeService.Companion.CHANNEL_ID_NODE import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.ui.components.AuthCheckView import to.bitkit.ui.components.IsOnlineTracker -import to.bitkit.ui.components.ToastOverlay +import to.bitkit.ui.components.ToastHost import to.bitkit.ui.onboarding.CreateWalletWithPassphraseScreen import to.bitkit.ui.onboarding.IntroScreen import to.bitkit.ui.onboarding.OnboardingSlidesScreen @@ -173,7 +173,7 @@ class MainActivity : FragmentActivity() { } val currentToast by appViewModel.currentToast.collectAsStateWithLifecycle() - ToastOverlay( + ToastHost( toast = currentToast, hazeState = hazeState, onDismiss = { appViewModel.hideToast() }, @@ -265,7 +265,7 @@ private fun OnboardingNav( walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = null) }.onFailure { - appViewModel.toast(it) + appViewModel.toaster.error(it) } } }, @@ -295,7 +295,7 @@ private fun OnboardingNav( appViewModel.resetIsAuthenticatedState() walletViewModel.restoreWallet(mnemonic, passphrase) }.onFailure { - appViewModel.toast(it) + appViewModel.toaster.error(it) } } } @@ -310,7 +310,7 @@ private fun OnboardingNav( appViewModel.resetIsAuthenticatedState() walletViewModel.createWallet(bip39Passphrase = passphrase) }.onFailure { - appViewModel.toast(it) + appViewModel.toaster.error(it) } } }, diff --git a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt index 5e81df27d..67eafc6c4 100644 --- a/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt +++ b/app/src/main/java/to/bitkit/ui/NodeInfoScreen.kt @@ -46,7 +46,7 @@ import to.bitkit.ext.createChannelDetails import to.bitkit.ext.formatToString import to.bitkit.ext.uri import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.models.formatToModernDisplay import to.bitkit.repositories.LightningState import to.bitkit.ui.components.BodyM @@ -75,9 +75,8 @@ fun NodeInfoScreen( navController: NavController, ) { val wallet = walletViewModel ?: return - val app = appViewModel ?: return + val toaster = toaster val settings = settingsViewModel ?: return - val context = LocalContext.current val isRefreshing by wallet.isRefreshing.collectAsStateWithLifecycle() val isDevModeEnabled by settings.isDevModeEnabled.collectAsStateWithLifecycle() @@ -91,10 +90,9 @@ fun NodeInfoScreen( onRefresh = { wallet.onPullToRefresh() }, onDisconnectPeer = { wallet.disconnectPeer(it) }, onCopy = { text -> - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = text + toaster.success( + title = ToastText(R.string.common__copied), + body = ToastText(text), ) }, ) diff --git a/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt b/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt index 0ef916d3f..37a65fcb8 100644 --- a/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt +++ b/app/src/main/java/to/bitkit/ui/components/IsOnlineTracker.kt @@ -5,18 +5,17 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R -import to.bitkit.models.Toast import to.bitkit.repositories.ConnectivityState +import to.bitkit.ui.toaster import to.bitkit.viewmodels.AppViewModel @Composable fun IsOnlineTracker( app: AppViewModel, ) { - val context = LocalContext.current + val toaster = toaster ?: return val connectivityState by app.isOnline.collectAsStateWithLifecycle(initialValue = ConnectivityState.CONNECTED) val (isFirstEmission, setIsFirstEmission) = remember { mutableStateOf(true) } @@ -30,18 +29,16 @@ fun IsOnlineTracker( when (connectivityState) { ConnectivityState.CONNECTED -> { - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.other__connection_back_title), - description = context.getString(R.string.other__connection_back_msg), + toaster.success( + title = R.string.other__connection_back_title, + body = R.string.other__connection_back_msg, ) } ConnectivityState.DISCONNECTED -> { - app.toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__connection_issue), - description = context.getString(R.string.other__connection_issue_explain), + toaster.warn( + title = R.string.other__connection_issue, + body = R.string.other__connection_issue_explain, ) } diff --git a/app/src/main/java/to/bitkit/ui/components/ToastView.kt b/app/src/main/java/to/bitkit/ui/components/ToastView.kt index aef3abf9d..f4389c030 100644 --- a/app/src/main/java/to/bitkit/ui/components/ToastView.kt +++ b/app/src/main/java/to/bitkit/ui/components/ToastView.kt @@ -53,6 +53,9 @@ import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.Toast +import to.bitkit.models.ToastText +import to.bitkit.models.ToastType +import to.bitkit.models.asString import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -66,9 +69,45 @@ private const val TINT_ALPHA = 0.32f private const val SHADOW_ALPHA = 0.4f private const val ELEVATION_DP = 10 +@Composable +fun ToastHost( + toast: Toast?, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState = rememberHazeState(blurEnabled = true), + onDragStart: () -> Unit = {}, + onDragEnd: () -> Unit = {}, +) { + Box( + contentAlignment = Alignment.TopCenter, + modifier = modifier.fillMaxSize(), + ) { + AnimatedContent( + targetState = toast, + transitionSpec = { + (fadeIn() + slideInVertically { -it }) + .togetherWith(fadeOut() + slideOutVertically { -it }) + .using(SizeTransform(clip = false)) + }, + contentAlignment = Alignment.TopCenter, + label = "toastAnimation", + ) { + if (it != null) { + ToastContent( + toast = it, + onDismiss = onDismiss, + hazeState = hazeState, + onDragStart = onDragStart, + onDragEnd = onDragEnd + ) + } + } + } +} + @OptIn(ExperimentalHazeMaterialsApi::class) @Composable -fun ToastView( +private fun ToastContent( toast: Toast, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -222,12 +261,12 @@ fun ToastView( .padding(16.dp) ) { BodyMSB( - text = toast.title, + text = toast.title.asString(), color = tintColor, ) - toast.description?.let { description -> + toast.body?.let { body -> Caption( - text = description, + text = body.asString(), color = Colors.White ) } @@ -260,107 +299,54 @@ fun ToastView( } } -@Composable -private fun ToastHost( - toast: Toast?, - hazeState: HazeState, - onDismiss: () -> Unit, - onDragStart: () -> Unit = {}, - onDragEnd: () -> Unit = {}, -) { - AnimatedContent( - targetState = toast, - transitionSpec = { - (fadeIn() + slideInVertically { -it }) - .togetherWith(fadeOut() + slideOutVertically { -it }) - .using(SizeTransform(clip = false)) - }, - contentAlignment = Alignment.TopCenter, - label = "toastAnimation", - ) { - if (it != null) { - ToastView( - toast = it, - onDismiss = onDismiss, - hazeState = hazeState, - onDragStart = onDragStart, - onDragEnd = onDragEnd - ) - } - } -} - -@Composable -fun ToastOverlay( - toast: Toast?, - onDismiss: () -> Unit, - modifier: Modifier = Modifier, - hazeState: HazeState = rememberHazeState(blurEnabled = true), - onDragStart: () -> Unit = {}, - onDragEnd: () -> Unit = {}, -) { - Box( - contentAlignment = Alignment.TopCenter, - modifier = modifier.fillMaxSize(), - ) { - ToastHost( - toast = toast, - hazeState = hazeState, - onDismiss = onDismiss, - onDragStart = onDragStart, - onDragEnd = onDragEnd - ) - } -} - @Preview(showSystemUi = true) @Composable -private fun ToastViewPreview() { +private fun Preview() { AppThemeSurface { ScreenColumn( verticalArrangement = Arrangement.spacedBy(16.dp), ) { - ToastView( + ToastContent( toast = Toast( - type = Toast.ToastType.WARNING, - title = "You're still offline", - description = "Check your connection to keep using Bitkit.", + type = ToastType.WARNING, + title = ToastText("You're still offline"), + body = ToastText("Check your connection to keep using Bitkit."), autoHide = true, ), onDismiss = {}, ) - ToastView( + ToastContent( toast = Toast( - type = Toast.ToastType.LIGHTNING, - title = "Instant Payments Ready", - description = "You can now pay anyone, anywhere, instantly.", + type = ToastType.LIGHTNING, + title = ToastText("Instant Payments Ready"), + body = ToastText("You can now pay anyone, anywhere, instantly."), autoHide = true, ), onDismiss = {}, ) - ToastView( + ToastContent( toast = Toast( - type = Toast.ToastType.SUCCESS, - title = "You're Back Online!", - description = "Successfully reconnected to the Internet.", + type = ToastType.SUCCESS, + title = ToastText("You're Back Online!"), + body = ToastText("Successfully reconnected to the Internet."), autoHide = true, ), onDismiss = {}, ) - ToastView( + ToastContent( toast = Toast( - type = Toast.ToastType.INFO, - title = "General Message", - description = "Used for neutral content to inform the user.", + type = ToastType.INFO, + title = ToastText("General Message"), + body = ToastText("Used for neutral content to inform the user."), autoHide = false, ), onDismiss = {}, ) - ToastView( + ToastContent( toast = Toast( - type = Toast.ToastType.ERROR, - title = "Error Toast", - description = "This is a toast message.", + type = ToastType.ERROR, + title = ToastText("Error Toast"), + body = ToastText("This is a toast message."), autoHide = true, ), onDismiss = {}, @@ -372,9 +358,9 @@ private fun ToastViewPreview() { @ReadOnlyComposable @Composable private fun Toast.tintColor(): Color = when (type) { - Toast.ToastType.SUCCESS -> Colors.Green - Toast.ToastType.INFO -> Colors.Blue - Toast.ToastType.LIGHTNING -> Colors.Purple - Toast.ToastType.WARNING -> Colors.Brand - Toast.ToastType.ERROR -> Colors.Red + ToastType.SUCCESS -> Colors.Green + ToastType.INFO -> Colors.Blue + ToastType.LIGHTNING -> Colors.Purple + ToastType.WARNING -> Colors.Brand + ToastType.ERROR -> Colors.Red } diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt index fd53d23a9..537ddff7b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryMnemonicViewModel.kt @@ -12,8 +12,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.keychain.Keychain -import to.bitkit.models.Toast -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -21,6 +20,7 @@ import javax.inject.Inject class RecoveryMnemonicViewModel @Inject constructor( @ApplicationContext private val context: Context, private val keychain: Keychain, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(RecoveryMnemonicUiState()) @@ -42,11 +42,7 @@ class RecoveryMnemonicViewModel @Inject constructor( isLoading = false, ) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.security__mnemonic_load_error), - description = context.getString(R.string.security__mnemonic_load_error), - ) + toaster.error(title = R.string.security__mnemonic_load_error) return@launch } @@ -66,7 +62,7 @@ class RecoveryMnemonicViewModel @Inject constructor( isLoading = false, ) } - ToastEventBus.send(e) + toaster.error(e) } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt index c0f19f378..df55fd3fc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/recovery/RecoveryViewModel.kt @@ -17,11 +17,10 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.env.Env -import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -32,6 +31,7 @@ class RecoveryViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, private val settingsStore: SettingsStore, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(RecoveryUiState()) @@ -73,10 +73,9 @@ class RecoveryViewModel @Inject constructor( isExportingLogs = false, ) } - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = context.getString(R.string.other__logs_export_error), + toaster.error( + title = R.string.common__error, + body = R.string.other__logs_export_error, ) } ) @@ -98,10 +97,9 @@ class RecoveryViewModel @Inject constructor( }.onFailure { fallbackError -> Logger.error("Failed to open support links", fallbackError, context = TAG) viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = context.getString(R.string.settings__support__link_error), + toaster.error( + title = R.string.common__error, + body = R.string.settings__support__link_error, ) } } @@ -119,12 +117,11 @@ class RecoveryViewModel @Inject constructor( fun wipeWallet() { viewModelScope.launch { walletRepo.wipeWallet().onFailure { error -> - ToastEventBus.send(error) + toaster.error(error) }.onSuccess { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.security__wiped_title), - description = context.getString(R.string.security__wiped_message), + toaster.success( + title = R.string.security__wiped_title, + body = R.string.security__wiped_message, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt index 84d7c76fd..4cda47e6d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/scanner/QrScanningScreen.kt @@ -65,8 +65,6 @@ import to.bitkit.R import to.bitkit.env.Env import to.bitkit.ext.getClipboardText import to.bitkit.ext.startActivityAppSettings -import to.bitkit.models.Toast -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput @@ -74,10 +72,11 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.utils.Logger -import to.bitkit.viewmodels.AppViewModel import java.util.concurrent.Executors const val SCAN_REQUEST_KEY = "SCAN_REQUEST" @@ -93,7 +92,7 @@ fun QrScanningScreen( onBack: () -> Unit = { navController.popBackStack() }, onScanSuccess: (String) -> Unit, ) { - val app = appViewModel ?: return + val toaster = toaster val (scanResult, setScanResult) = remember { mutableStateOf(null) } @@ -140,7 +139,7 @@ fun QrScanningScreen( val context = LocalContext.current val previewView = remember { PreviewView(context) } val preview = remember { Preview.Builder().build() } - val analyzer = remember { + val analyzer = remember(toaster) { QrCodeAnalyzer { result -> if (result.isSuccess) { val qrCode = result.getOrThrow() @@ -149,10 +148,9 @@ fun QrScanningScreen( } else { val error = requireNotNull(result.exceptionOrNull()) Logger.error("Failed to scan QR code", error) - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), + toaster.error( + title = R.string.other__qr_error_header, + body = R.string.other__qr_error_text, ) } } @@ -166,12 +164,12 @@ fun QrScanningScreen( val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), onResult = { uri -> - uri?.let { processImageFromGallery(context, it, setScanResult, onError = { e -> app.toast(e) }) } + uri?.let { processImageFromGallery(context, it, setScanResult, onError = { e -> toaster.error(e) }) } } ) val pickMedia = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - uri?.let { processImageFromGallery(context, it, setScanResult, onError = { e -> app.toast(e) }) } + uri?.let { processImageFromGallery(context, it, setScanResult, onError = { e -> toaster.error(e) }) } } LaunchedEffect(lensFacing) { @@ -210,7 +208,7 @@ fun QrScanningScreen( context.startActivityAppSettings() }, onClickRetry = cameraPermissionState::launchPermissionRequest, - onClickPaste = handlePaste(context, app, setScanResult), + onClickPaste = handlePaste(context, toaster, setScanResult), onBack = onBack, ) }, @@ -239,7 +237,7 @@ fun QrScanningScreen( galleryLauncher.launch("image/*") } }, - onPasteFromClipboard = handlePaste(context, app, setScanResult), + onPasteFromClipboard = handlePaste(context, toaster, setScanResult), onSubmitDebug = setScanResult, ) } @@ -250,15 +248,14 @@ fun QrScanningScreen( @Composable private fun handlePaste( context: Context, - app: AppViewModel, + toaster: Toaster, setScanResult: (String?) -> Unit, ): () -> Unit = { val clipboard = context.getClipboardText()?.trim() if (clipboard.isNullOrBlank()) { - app.toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__send_clipboard_empty_title), - description = context.getString(R.string.wallet__send_clipboard_empty_text), + toaster.warn( + title = R.string.wallet__send_clipboard_empty_title, + body = R.string.wallet__send_clipboard_empty_text, ) } setScanResult(clipboard) diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index ff033c2ce..eb600dc5b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -14,10 +14,8 @@ import androidx.navigation.NavController import org.lightningdevkit.ldknode.Network import to.bitkit.R import to.bitkit.env.Env -import to.bitkit.models.Toast import to.bitkit.ui.Routes import to.bitkit.ui.activityListViewModel -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.settings.SectionHeader import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsTextButtonRow @@ -26,6 +24,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.util.shareZipFile +import to.bitkit.ui.toaster import to.bitkit.viewmodels.DevSettingsViewModel @Composable @@ -33,7 +32,7 @@ fun DevSettingsScreen( navController: NavController, viewModel: DevSettingsViewModel = hiltViewModel(), ) { - val app = appViewModel ?: return + val toaster = toaster val activity = activityListViewModel ?: return val settings = settingsViewModel ?: return val context = LocalContext.current @@ -78,63 +77,63 @@ fun DevSettingsScreen( title = "Reset Settings State", onClick = { settings.reset() - app.toast(type = Toast.ToastType.SUCCESS, title = "Settings state reset") + toaster.success(title = "Settings state reset") } ) SettingsTextButtonRow( title = "Reset All Activities", onClick = { activity.removeAllActivities() - app.toast(type = Toast.ToastType.SUCCESS, title = "Activities removed") + toaster.success(title = "Activities removed") } ) SettingsTextButtonRow( title = "Reset Backup State", onClick = { viewModel.resetBackupState() - app.toast(type = Toast.ToastType.SUCCESS, title = "Backup state reset") + toaster.success(title = "Backup state reset") } ) SettingsTextButtonRow( title = "Reset Widgets State", onClick = { viewModel.resetWidgetsState() - app.toast(type = Toast.ToastType.SUCCESS, title = "Widgets state reset") + toaster.success(title = "Widgets state reset") } ) SettingsTextButtonRow( title = "Refresh Currency Rates", onClick = { viewModel.refreshCurrencyRates() - app.toast(type = Toast.ToastType.SUCCESS, title = "Currency rates refreshed") + toaster.success(title = "Currency rates refreshed") } ) SettingsTextButtonRow( title = "Reset App Database", onClick = { viewModel.resetDatabase() - app.toast(type = Toast.ToastType.SUCCESS, title = "Database state reset") + toaster.success(title = "Database state reset") } ) SettingsTextButtonRow( title = "Reset Blocktank State", onClick = { viewModel.resetBlocktankState() - app.toast(type = Toast.ToastType.SUCCESS, title = "Blocktank state reset") + toaster.success(title = "Blocktank state reset") } ) SettingsTextButtonRow( title = "Reset Cache Store", onClick = { viewModel.resetCacheStore() - app.toast(type = Toast.ToastType.SUCCESS, title = "Cache store reset") + toaster.success(title = "Cache store reset") } ) SettingsTextButtonRow( title = "Wipe App", onClick = { viewModel.wipeWallet() - app.toast(type = Toast.ToastType.SUCCESS, title = "Wallet wiped") + toaster.success(title = "Wallet wiped") } ) @@ -145,14 +144,14 @@ fun DevSettingsScreen( onClick = { val count = 100 activity.generateRandomTestData(count) - app.toast(type = Toast.ToastType.SUCCESS, title = "Generated $count test activities") + toaster.success(title = "Generated $count test activities") } ) SettingsTextButtonRow( "Fake New BG Receive", onClick = { viewModel.fakeBgReceive() - app.toast(type = Toast.ToastType.INFO, title = "Restart app to see the payment received sheet") + toaster.info(title = "Restart app to see the payment received sheet") } ) SettingsTextButtonRow( diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt index 3b3521c2e..c8946335f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SavingsProgressScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.keepScreenOn import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -26,7 +25,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import to.bitkit.R -import to.bitkit.models.Toast import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton @@ -37,6 +35,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.transfer.components.TransferAnimationView import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.utils.removeAccentTags import to.bitkit.ui.utils.withAccent import to.bitkit.ui.utils.withAccentBoldBright @@ -52,7 +51,7 @@ fun SavingsProgressScreen( onContinueClick: () -> Unit = {}, onTransferUnavailable: () -> Unit = {}, ) { - val context = LocalContext.current + val toaster = toaster var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } // Effect to close channels & update UI @@ -70,10 +69,9 @@ fun SavingsProgressScreen( if (nonTrustedChannels.isEmpty()) { // All channels are trusted peers - show error and navigate back immediately - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), + toaster.error( + title = R.string.lightning__close_error, + body = R.string.lightning__close_error_msg, ) onTransferUnavailable() } else { @@ -81,10 +79,9 @@ fun SavingsProgressScreen( channels = nonTrustedChannels, onGiveUp = { app.showSheet(Sheet.ForceTransfer) }, onTransferUnavailable = { - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), + toaster.error( + title = R.string.lightning__close_error, + body = R.string.lightning__close_error_msg, ) onTransferUnavailable() }, diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt index 7e92fad64..bd9da7188 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAdvancedScreen.kt @@ -26,10 +26,8 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.ext.mockOrder -import to.bitkit.models.Toast import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight @@ -45,6 +43,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.utils.withAccent import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.TransferEffect @@ -63,7 +62,7 @@ fun SpendingAdvancedScreen( amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val currentOnOrderCreated by rememberUpdatedState(onOrderCreated) - val app = appViewModel ?: return + val toaster = toaster val state by viewModel.spendingUiState.collectAsStateWithLifecycle() val order = state.order ?: return val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() @@ -85,15 +84,14 @@ fun SpendingAdvancedScreen( TransferEffect.OnOrderCreated -> currentOnOrderCreated() is TransferEffect.ToastException -> { isLoading = false - app.toast(effect.e) + toaster.error(effect.e) } is TransferEffect.ToastError -> { isLoading = false - app.toast( - type = Toast.ToastType.ERROR, + toaster.error( title = effect.title, - description = effect.description, + body = effect.body, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt index d313421c2..7e015b9cc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/SpendingAmountScreen.kt @@ -72,7 +72,7 @@ fun SpendingAmountScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> onOrderCreated() - is TransferEffect.ToastError -> toast(effect.title, effect.description) + is TransferEffect.ToastError -> toast(effect.title, effect.body) is TransferEffect.ToastException -> toastException(effect.e) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt index f2cd73f25..4981a7958 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalFeeCustomScreen.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL -import to.bitkit.models.Toast import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Display @@ -39,7 +38,6 @@ import to.bitkit.ui.currencyViewModel import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -52,7 +50,6 @@ fun ExternalFeeCustomScreen( val uiState by viewModel.uiState.collectAsState() val currency = currencyViewModel ?: return val scope = rememberCoroutineScope() - val context = LocalContext.current var input by remember { @@ -88,18 +85,11 @@ fun ExternalFeeCustomScreen( } }, onContinue = { - val feeRate = input.toUIntOrNull() ?: 0u - if (feeRate == 0u) { - scope.launch { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__min_possible_fee_rate), - description = context.getString(R.string.wallet__min_possible_fee_rate_msg), - ) + scope.launch { + if (viewModel.validateCustomFeeRate()) { + onBack() } - return@Content } - onBack() }, onBack = onBack, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt index efd087df3..1df49579a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/ExternalNodeViewModel.kt @@ -20,7 +20,7 @@ import to.bitkit.data.SettingsStore import to.bitkit.ext.WatchResult import to.bitkit.ext.of import to.bitkit.ext.watchUntil -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.formatToModernDisplay @@ -28,7 +28,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.SideEffect import to.bitkit.ui.screens.transfer.external.ExternalNodeContract.UiState -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -40,6 +40,7 @@ class ExternalNodeViewModel @Inject constructor( private val lightningRepo: LightningRepo, private val settingsStore: SettingsStore, private val transferRepo: to.bitkit.repositories.TransferRepo, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) val uiState = _uiState.asStateFlow() @@ -73,10 +74,9 @@ class ExternalNodeViewModel @Inject constructor( _uiState.update { it.copy(peer = peer) } setEffect(SideEffect.ConnectionSuccess) } else { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__error_add_title), - description = context.getString(R.string.lightning__error_add), + toaster.error( + title = R.string.lightning__error_add_title, + body = R.string.lightning__error_add, ) } } @@ -89,10 +89,7 @@ class ExternalNodeViewModel @Inject constructor( if (result.isSuccess) { _uiState.update { it.copy(peer = result.getOrNull()) } } else { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__error_add_uri), - ) + toaster.error(title = R.string.lightning__error_add_uri) } } } @@ -102,11 +99,12 @@ class ExternalNodeViewModel @Inject constructor( if (sats > maxAmount) { viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", maxAmount.formatToModernDisplay()), + toaster.error( + title = ToastText(R.string.lightning__spending_amount__error_max__title), + body = ToastText( + context.getString(R.string.lightning__spending_amount__error_max__description) + .replace("{amount}", maxAmount.formatToModernDisplay()) + ), ) } return @@ -134,6 +132,18 @@ class ExternalNodeViewModel @Inject constructor( updateNetworkFee() } + suspend fun validateCustomFeeRate(): Boolean { + val feeRate = _uiState.value.customFeeRate ?: 0u + if (feeRate == 0u) { + toaster.info( + title = R.string.wallet__min_possible_fee_rate, + body = R.string.wallet__min_possible_fee_rate_msg, + ) + return false + } + return true + } + private fun updateNetworkFee() { viewModelScope.launch { val amountSats = _uiState.value.amount.sats @@ -180,11 +190,12 @@ class ExternalNodeViewModel @Inject constructor( }.onFailure { e -> val error = e.message.orEmpty() Logger.warn("Error opening channel with peer: '${_uiState.value.peer}': '$error'") - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__error_channel_purchase), - description = context.getString(R.string.lightning__error_channel_setup_msg) - .replace("{raw}", error), + toaster.error( + title = ToastText(R.string.lightning__error_channel_purchase), + body = ToastText( + context.getString(R.string.lightning__error_channel_setup_msg) + .replace("{raw}", error) + ), ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt index 52401cc90..ccb7ef4ad 100644 --- a/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/transfer/external/LnurlChannelViewModel.kt @@ -12,16 +12,17 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R import to.bitkit.ext.of -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.repositories.LightningRepo import to.bitkit.ui.Routes -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import javax.inject.Inject @HiltViewModel class LnurlChannelViewModel @Inject constructor( @ApplicationContext private val context: Context, private val lightningRepo: LightningRepo, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(LnurlChannelUiState()) @@ -68,10 +69,9 @@ class LnurlChannelViewModel @Inject constructor( lightningRepo.requestLnurlChannel(callback = params.callback, k1 = params.k1, nodeId = nodeId) .onSuccess { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.other__lnurl_channel_success_title), - description = context.getString(R.string.other__lnurl_channel_success_msg_no_peer), + toaster.success( + title = R.string.other__lnurl_channel_success_title, + body = R.string.other__lnurl_channel_success_msg_no_peer, ) _uiState.update { it.copy(isConnected = true) } }.onFailure { error -> @@ -83,10 +83,9 @@ class LnurlChannelViewModel @Inject constructor( } suspend fun errorToast(error: Throwable) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__lnurl_channel_error), - description = error.message ?: "Unknown error", + toaster.error( + title = ToastText(R.string.other__lnurl_channel_error), + body = ToastText(error.message ?: context.getString(R.string.common__error_body)), ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt index 6bc8520b6..ba0ce7d48 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityDetailScreen.kt @@ -58,9 +58,7 @@ import to.bitkit.ext.toActivityItemDate import to.bitkit.ext.toActivityItemTime import to.bitkit.ext.totalValue import to.bitkit.models.FeeRate.Companion.getFeeShortDescription -import to.bitkit.models.Toast import to.bitkit.ui.Routes -import to.bitkit.ui.appViewModel import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodySSB @@ -81,6 +79,7 @@ import to.bitkit.ui.sheets.BoostTransactionSheet import to.bitkit.ui.sheets.ComingSoonSheet import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getScreenTitleRes import to.bitkit.viewmodels.ActivityDetailViewModel @@ -163,7 +162,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val app = appViewModel ?: return@Box + val toaster = toaster val copyToastTitle = stringResource(R.string.common__copied) val tags by detailViewModel.tags.collectAsStateWithLifecycle() @@ -194,7 +193,6 @@ fun ActivityDetailScreen( } } - val context = LocalContext.current val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } @@ -227,10 +225,9 @@ fun ActivityDetailScreen( isCpfpChild = isCpfpChild, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> - app.toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = copyToastTitle, - description = text.ellipsisMiddle(40) + body = text.ellipsisMiddle(40) ) }, feeRates = feeRates, @@ -250,36 +247,32 @@ fun ActivityDetailScreen( onDismiss = detailViewModel::onDismissBoostSheet, item = it, onSuccess = { - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.wallet__boost_success_title), - description = context.getString(R.string.wallet__boost_success_msg), + toaster.success( + title = R.string.wallet__boost_success_title, + body = R.string.wallet__boost_success_msg, testTag = "BoostSuccessToast" ) listViewModel.resync() onCloseClick() }, onFailure = { - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__boost_error_title), - description = context.getString(R.string.wallet__boost_error_msg), + toaster.error( + title = R.string.wallet__boost_error_title, + body = R.string.wallet__boost_error_msg, testTag = "BoostFailureToast" ) detailViewModel.onDismissBoostSheet() }, onMaxFee = { - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__send_fee_error), - description = context.getString(R.string.wallet__send_fee_error_max) + toaster.error( + title = R.string.wallet__send_fee_error, + body = R.string.wallet__send_fee_error_max, ) }, onMinFee = { - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__send_fee_error), - description = context.getString(R.string.wallet__send_fee_error_min) + toaster.error( + title = R.string.wallet__send_fee_error, + body = R.string.wallet__send_fee_error_min, ) } ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt index f6b8e16f0..362166753 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/activity/ActivityExploreScreen.kt @@ -45,9 +45,7 @@ import to.bitkit.ext.create import to.bitkit.ext.ellipsisMiddle import to.bitkit.ext.isSent import to.bitkit.ext.totalValue -import to.bitkit.models.Toast import to.bitkit.ui.Routes -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BalanceHeaderView import to.bitkit.ui.components.BodySSB import to.bitkit.ui.components.Caption13Up @@ -59,6 +57,7 @@ import to.bitkit.ui.screens.wallets.activity.components.ActivityIcon import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.utils.copyToClipboard import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.utils.getScreenTitleRes @@ -131,7 +130,7 @@ fun ActivityExploreScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val app = appViewModel ?: return@ScreenColumn + val toaster = toaster val context = LocalContext.current val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() @@ -166,10 +165,9 @@ fun ActivityExploreScreen( txDetails = txDetails, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> - app.toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = toastMessage, - description = text.ellipsisMiddle(40), + body = text.ellipsisMiddle(40), ) }, onClickExplore = { txid -> diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt index 15ecf20d8..d9ff266d7 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/receive/ReceiveAmountScreen.kt @@ -31,7 +31,6 @@ import to.bitkit.R import to.bitkit.models.NodeLifecycleState import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up @@ -49,6 +48,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger import to.bitkit.viewmodels.AmountInputViewModel @@ -62,7 +62,7 @@ fun ReceiveAmountScreen( currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { - val app = appViewModel ?: return + val toaster = toaster val wallet = walletViewModel ?: return val blocktank = blocktankViewModel ?: return val lightningState by wallet.lightningState.collectAsStateWithLifecycle() @@ -106,7 +106,7 @@ fun ReceiveAmountScreen( ) ) }.onFailure { e -> - app.toast(e) + toaster.error(e) Logger.error("Failed to create CJIT", e) } isCreatingInvoice = false diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index 292aeaca3..4c7d2de4b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices.NEXUS_5 @@ -32,12 +31,10 @@ import to.bitkit.ext.maxWithdrawableSat import to.bitkit.models.BalanceState import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.NodeLifecycleState -import to.bitkit.models.Toast import to.bitkit.models.safe import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalBalances import to.bitkit.ui.LocalCurrencies -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -57,6 +54,7 @@ import to.bitkit.ui.shared.modifiers.sheetHeight import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.viewmodels.AmountInputUiState import to.bitkit.viewmodels.AmountInputViewModel import to.bitkit.viewmodels.LnurlParams @@ -76,8 +74,7 @@ fun SendAmountScreen( currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { - val app = appViewModel - val context = LocalContext.current + val toaster = toaster val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentOnEvent by rememberUpdatedState(onEvent) @@ -108,10 +105,9 @@ fun SendAmountScreen( }.takeIf { canGoBack }, onClickMax = { maxSats -> if (uiState.lnurl == null) { - app?.toast( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__send_max_spending__title), - description = context.getString(R.string.wallet__send_max_spending__description) + toaster.info( + title = R.string.wallet__send_max_spending__title, + body = R.string.wallet__send_max_spending__description, ) } amountInputViewModel.setSats(maxSats, currencies) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt index 851027e26..3f5c1bb07 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendCoinSelectionViewModel.kt @@ -1,30 +1,35 @@ package to.bitkit.ui.screens.wallets.send +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.synonym.bitkitcore.Activity import com.synonym.bitkitcore.Activity.Onchain import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.SpendableUtxo +import to.bitkit.R import to.bitkit.di.BgDispatcher import to.bitkit.env.Defaults import to.bitkit.ext.rawId import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.LightningRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @HiltViewModel class SendCoinSelectionViewModel @Inject constructor( + @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val activityRepo: ActivityRepo, + private val toaster: Toaster, ) : ViewModel() { companion object { private const val TAG = "SendCoinSelectionViewModel" @@ -67,7 +72,10 @@ class SendCoinSelectionViewModel @Inject constructor( } }.onFailure { Logger.error("Failed to load UTXOs for coin selection", it, context = TAG) - ToastEventBus.send(Exception("Failed to load UTXOs: ${it.message}")) + toaster.error( + context.getString(R.string.wallet__error_utxo_load) + .replace("{raw}", it.message.orEmpty()) + ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt index cdd4fac8f..105bafe93 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModel.kt @@ -12,13 +12,12 @@ import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.getSatsPerVByteFor import to.bitkit.models.FeeRate -import to.bitkit.models.Toast import to.bitkit.models.TransactionSpeed import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.ui.components.KEY_DELETE -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.viewmodels.SendUiState import javax.inject.Inject @@ -32,6 +31,7 @@ class SendFeeViewModel @Inject constructor( private val currencyRepo: CurrencyRepo, private val walletRepo: WalletRepo, @ApplicationContext private val context: Context, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(SendFeeUiState()) val uiState = _uiState.asStateFlow() @@ -105,19 +105,17 @@ class SendFeeViewModel @Inject constructor( // TODO update to use minimum instead of slow when using mempool api val minSatsPerVByte = sendUiState.feeRates?.slow ?: 1u if (satsPerVByte < minSatsPerVByte) { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__min_possible_fee_rate), - description = context.getString(R.string.wallet__min_possible_fee_rate_msg) + toaster.info( + title = R.string.wallet__min_possible_fee_rate, + body = R.string.wallet__min_possible_fee_rate_msg, ) return false } if (satsPerVByte > maxSatsPerVByte) { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.wallet__max_possible_fee_rate), - description = context.getString(R.string.wallet__max_possible_fee_rate_msg) + toaster.info( + title = R.string.wallet__max_possible_fee_rate, + body = R.string.wallet__max_possible_fee_rate_msg, ) return false } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt index 26e487a9d..eb33429c9 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendRecipientScreen.kt @@ -59,8 +59,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import to.bitkit.R import to.bitkit.ext.startActivityAppSettings -import to.bitkit.models.Toast -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BottomSheetPreview @@ -76,6 +74,7 @@ import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Shapes import to.bitkit.ui.theme.TRANSITION_SCREEN_MS +import to.bitkit.ui.toaster import to.bitkit.ui.utils.withAccent import to.bitkit.utils.AppError import to.bitkit.utils.Logger @@ -91,7 +90,7 @@ fun SendRecipientScreen( onEvent: (SendEvent) -> Unit, modifier: Modifier = Modifier, ) { - val app = appViewModel + val toaster = toaster // Context & lifecycle val context = LocalContext.current @@ -139,10 +138,9 @@ fun SendRecipientScreen( } else { val error = requireNotNull(result.exceptionOrNull()) Logger.error("Scan failed", error, context = TAG) - app?.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__qr_error_text), + toaster.error( + title = R.string.other__qr_error_header, + body = R.string.other__qr_error_text, ) } } @@ -173,11 +171,10 @@ fun SendRecipientScreen( isCameraInitialized = true }.onFailure { Logger.error("Camera initialization failed", it, context = TAG) - app?.toast( - type = Toast.ToastType.ERROR, + toaster.error( title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__camera_init_error) - .replace("{message}", it.message.orEmpty()) + body = context.getString(R.string.other__camera_init_error) + .replace("{message}", it.message.orEmpty()), ) isCameraInitialized = false } @@ -206,7 +203,7 @@ fun SendRecipientScreen( onEvent(SendEvent.AddressContinue(qrCode)) } - val handleGalleryError: (Throwable) -> Unit = { app?.toast(it) } + val handleGalleryError: (Throwable) -> Unit = { toaster.error(it) } val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent(), diff --git a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt index fcc6ff45c..6f9ced42a 100644 --- a/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/BlocktankRegtestScreen.kt @@ -28,8 +28,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import kotlinx.coroutines.launch import to.bitkit.R -import to.bitkit.models.Toast -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption import to.bitkit.ui.components.Caption13Up @@ -39,6 +37,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger @@ -50,7 +49,7 @@ fun BlocktankRegtestScreen( ) { val coroutineScope = rememberCoroutineScope() val wallet = walletViewModel ?: return - val app = appViewModel ?: return + val toaster = toaster val walletState by wallet.walletState.collectAsStateWithLifecycle() ScreenColumn { @@ -112,17 +111,15 @@ fun BlocktankRegtestScreen( val sats = depositAmount.toULongOrNull() ?: error("Invalid deposit amount: $depositAmount") val txId = viewModel.regtestDeposit(depositAddress, sats) Logger.debug("Deposit successful with txId: $txId") - app.toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = "Success", - description = "Deposit successful. TxID: $txId", + body = "Deposit successful. TxID: $txId", ) }.onFailure { Logger.error("Deposit failed", it) - app.toast( - type = Toast.ToastType.ERROR, + toaster.error( title = "Failed to deposit", - description = it.message.orEmpty(), + body = it.message.orEmpty(), ) } @@ -158,17 +155,15 @@ fun BlocktankRegtestScreen( mineBlockCount.toUIntOrNull() ?: error("Invalid block count: $mineBlockCount") viewModel.regtestMine(count) Logger.debug("Successfully mined $count blocks") - app.toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = "Success", - description = "Successfully mined $count blocks", + body = "Successfully mined $count blocks", ) }.onFailure { Logger.error("Mining failed", it) - app.toast( - type = Toast.ToastType.ERROR, + toaster.error( title = "Failed to mine", - description = it.message.orEmpty(), + body = it.message.orEmpty(), ) } isMining = false @@ -210,17 +205,15 @@ fun BlocktankRegtestScreen( val amount = if (paymentAmount.isEmpty()) null else paymentAmount.toULongOrNull() val paymentId = viewModel.regtestPay(paymentInvoice, amount) Logger.debug("Payment successful with ID: $paymentId") - app.toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = "Success", - description = "Payment successful. ID: $paymentId", + body = "Payment successful. ID: $paymentId", ) }.onFailure { Logger.error("Payment failed", it) - app.toast( - type = Toast.ToastType.ERROR, + toaster.error( title = "Failed to pay invoice from LND", - description = it.message.orEmpty(), + body = it.message.orEmpty(), ) } } @@ -277,14 +270,13 @@ fun BlocktankRegtestScreen( forceCloseAfterS = closeAfter, ) Logger.debug("Channel closed successfully with txId: $closingTxId") - app.toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = "Success", - description = "Channel closed. Closing TxID: $closingTxId" + body = "Channel closed. Closing TxID: $closingTxId" ) }.onFailure { Logger.error("Channel close failed", it) - app.toast(it) + toaster.error(it) } } }, diff --git a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt index f08ac96af..c18e72c28 100644 --- a/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/SettingsScreen.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -26,9 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import to.bitkit.R -import to.bitkit.models.Toast import to.bitkit.ui.Routes -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.navigateToAboutSettings import to.bitkit.ui.navigateToAdvancedSettings @@ -42,6 +39,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.toaster private const val DEV_MODE_TAP_THRESHOLD = 5 @@ -49,12 +47,11 @@ private const val DEV_MODE_TAP_THRESHOLD = 5 fun SettingsScreen( navController: NavController, ) { - val app = appViewModel ?: return + val toaster = toaster val settings = settingsViewModel ?: return val isDevModeEnabled by settings.isDevModeEnabled.collectAsStateWithLifecycle() var enableDevModeTapCount by remember { mutableIntStateOf(0) } val haptic = LocalHapticFeedback.current - val context = LocalContext.current SettingsScreenContent( isDevModeEnabled = isDevModeEnabled, @@ -75,22 +72,19 @@ fun SettingsScreen( settings.setIsDevModeEnabled(newValue) haptic.performHapticFeedback(HapticFeedbackType.LongPress) - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString( - if (newValue) { - R.string.settings__dev_enabled_title - } else { - R.string.settings__dev_disabled_title - } - ), - description = context.getString( - if (newValue) { - R.string.settings__dev_enabled_message - } else { - R.string.settings__dev_disabled_message - } - ), + val titleRes = if (newValue) { + R.string.settings__dev_enabled_title + } else { + R.string.settings__dev_disabled_title + } + val bodyRes = if (newValue) { + R.string.settings__dev_enabled_message + } else { + R.string.settings__dev_disabled_message + } + toaster.success( + title = titleRes, + body = bodyRes, ) enableDevModeTapCount = 0 } diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt index 09353a86c..7cdb1a559 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/AddressViewerScreen.kt @@ -30,9 +30,8 @@ import androidx.navigation.NavController import to.bitkit.R import to.bitkit.ext.setClipboardText import to.bitkit.models.AddressModel -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.models.formatToModernDisplay -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption @@ -49,6 +48,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.utils.BlockExplorerType import to.bitkit.ui.utils.getBlockExplorerUrl @@ -57,8 +57,8 @@ fun AddressViewerScreen( navController: NavController, viewModel: AddressViewerViewModel = hiltViewModel(), ) { - val app = appViewModel ?: return val context = LocalContext.current + val toaster = toaster val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -77,10 +77,9 @@ fun AddressViewerScreen( onGenerateMoreAddresses = viewModel::loadMoreAddresses, onCopy = { text -> context.setClipboardText(text) - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = text, + toaster.success( + title = ToastText(R.string.common__copied), + body = ToastText(text), ) } ) diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt index 72531e42f..22fbf3422 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigScreen.kt @@ -31,8 +31,7 @@ import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServerPeer -import to.bitkit.models.Toast -import to.bitkit.ui.appViewModel +import to.bitkit.models.ToastText import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight @@ -49,6 +48,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.scanner.SCAN_RESULT_KEY import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster @Composable fun ElectrumConfigScreen( @@ -57,8 +57,8 @@ fun ElectrumConfigScreen( viewModel: ElectrumConfigViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val app = appViewModel ?: return val context = LocalContext.current + val toaster = toaster // Handle result from Scanner LaunchedEffect(savedStateHandle) { @@ -74,19 +74,19 @@ fun ElectrumConfigScreen( LaunchedEffect(uiState.connectionResult) { uiState.connectionResult?.let { result -> if (result.isSuccess) { - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.settings__es__server_updated_title), - description = context.getString(R.string.settings__es__server_updated_message) - .replace("{host}", uiState.host) - .replace("{port}", uiState.port), + toaster.success( + title = ToastText(R.string.settings__es__server_updated_title), + body = ToastText( + context.getString(R.string.settings__es__server_updated_message) + .replace("{host}", uiState.host) + .replace("{port}", uiState.port) + ), testTag = "ElectrumUpdatedToast", ) } else { - app.toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__es__server_error), - description = context.getString(R.string.settings__es__server_error_description), + toaster.warn( + title = R.string.settings__es__server_error, + body = R.string.settings__es__server_error_description, testTag = "ElectrumErrorToast", ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt index ed41850f1..c3b53c322 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/ElectrumConfigViewModel.kt @@ -23,10 +23,10 @@ import to.bitkit.models.ElectrumProtocol import to.bitkit.models.ElectrumServer import to.bitkit.models.ElectrumServerPeer import to.bitkit.models.MAX_VALID_PORT -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.models.getDefaultPort import to.bitkit.repositories.LightningRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import javax.inject.Inject @HiltViewModel @@ -35,6 +35,7 @@ class ElectrumConfigViewModel @Inject constructor( @ApplicationContext private val context: Context, private val settingsStore: SettingsStore, private val lightningRepo: LightningRepo, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(ElectrumConfigUiState()) @@ -246,10 +247,9 @@ class ElectrumConfigViewModel @Inject constructor( viewModelScope.launch { val validationError = validateInput() if (validationError != null) { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__es__error_peer), - description = validationError, + toaster.warn( + title = ToastText(R.string.settings__es__error_peer), + body = ToastText(validationError), ) } else { connectToServer() @@ -268,10 +268,9 @@ class ElectrumConfigViewModel @Inject constructor( val validationError = validateInput(host, port) if (validationError != null) { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.settings__es__error_peer), - description = validationError, + toaster.warn( + title = ToastText(R.string.settings__es__error_peer), + body = ToastText(validationError), ) return@launch } diff --git a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt index cf74847db..276f42340 100644 --- a/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/advanced/RgsServerScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction @@ -26,8 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import kotlinx.coroutines.flow.filterNotNull import to.bitkit.R -import to.bitkit.models.Toast -import to.bitkit.ui.appViewModel +import to.bitkit.models.ToastText import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight @@ -42,6 +40,7 @@ import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.scanner.SCAN_RESULT_KEY import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster @Composable fun RgsServerScreen( @@ -50,8 +49,7 @@ fun RgsServerScreen( viewModel: RgsServerViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val app = appViewModel ?: return - val context = LocalContext.current + val toaster = toaster // Handle result from Scanner LaunchedEffect(savedStateHandle) { @@ -67,17 +65,15 @@ fun RgsServerScreen( LaunchedEffect(uiState.connectionResult) { uiState.connectionResult?.let { result -> if (result.isSuccess) { - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.settings__rgs__update_success_title), - description = context.getString(R.string.settings__rgs__update_success_description), + toaster.success( + title = R.string.settings__rgs__update_success_title, + body = R.string.settings__rgs__update_success_description, testTag = "RgsUpdatedToast", ) } else { - app.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__ldk_start_error_title), - description = result.exceptionOrNull()?.message ?: "Unknown error", + toaster.error( + title = ToastText(R.string.wallet__ldk_start_error_title), + body = ToastText(result.exceptionOrNull()?.message ?: "Unknown error"), testTag = "RgsErrorToast", ) } diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt index 3c41f83b5..a5e6b80ff 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/BackupNavSheetViewModel.kt @@ -19,11 +19,10 @@ import to.bitkit.data.SettingsStore import to.bitkit.data.keychain.Keychain import to.bitkit.models.BackupCategory import to.bitkit.models.HealthState -import to.bitkit.models.Toast import to.bitkit.repositories.HealthRepo import to.bitkit.ui.settings.backups.BackupContract.SideEffect import to.bitkit.ui.settings.backups.BackupContract.UiState -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -35,6 +34,7 @@ class BackupNavSheetViewModel @Inject constructor( private val keychain: Keychain, private val healthRepo: HealthRepo, private val cacheStore: CacheStore, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(UiState()) @@ -86,10 +86,9 @@ class BackupNavSheetViewModel @Inject constructor( } }.onFailure { Logger.error("Error loading mnemonic", it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.security__mnemonic_error), - description = context.getString(R.string.security__mnemonic_error_description), + toaster.warn( + title = R.string.security__mnemonic_error, + body = R.string.security__mnemonic_error_description, ) } } @@ -154,6 +153,15 @@ class BackupNavSheetViewModel @Inject constructor( fun resetState() { _uiState.update { UiState() } } + + fun onMnemonicCopied() { + viewModelScope.launch { + toaster.success( + title = R.string.common__copied, + body = R.string.security__mnemonic_copied, + ) + } + } } interface BackupContract { diff --git a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt index 0468834aa..843441ce0 100644 --- a/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/backups/ShowMnemonicScreen.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import to.bitkit.R import to.bitkit.ext.setClipboardText -import to.bitkit.models.Toast import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.BottomSheetPreview @@ -51,7 +50,6 @@ import to.bitkit.ui.components.SheetSize import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.effects.BlockScreenshots import to.bitkit.ui.shared.modifiers.sheetHeight -import to.bitkit.ui.shared.toast.ToastEventBus import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -63,24 +61,18 @@ fun ShowMnemonicScreen( uiState: BackupContract.UiState, onRevealClick: () -> Unit, onContinueClick: () -> Unit, + onMnemonicCopied: () -> Unit, ) { BlockScreenshots() val context = LocalContext.current - val scope = rememberCoroutineScope() ShowMnemonicContent( mnemonic = uiState.bip39Mnemonic, showMnemonic = uiState.showMnemonic, onRevealClick = onRevealClick, onCopyClick = { context.setClipboardText(uiState.bip39Mnemonic) - scope.launch { - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = context.getString(R.string.security__mnemonic_copied), - ) - } + onMnemonicCopied() }, onContinueClick = onContinueClick, ) diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt index 04795bd4d..75c3e540c 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/ChannelDetailScreen.kt @@ -55,9 +55,8 @@ import to.bitkit.ext.DatePattern import to.bitkit.ext.amountOnClose import to.bitkit.ext.createChannelDetails import to.bitkit.ext.setClipboardText -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.ui.Routes -import to.bitkit.ui.appViewModel import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.CaptionB import to.bitkit.ui.components.ChannelStatusUi @@ -74,6 +73,7 @@ import to.bitkit.ui.settings.lightning.components.ChannelStatusView import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.toaster import to.bitkit.ui.utils.getBlockExplorerUrl import to.bitkit.ui.walletViewModel import java.time.Instant @@ -87,7 +87,7 @@ fun ChannelDetailScreen( viewModel: LightningConnectionsViewModel, ) { val context = LocalContext.current - val app = appViewModel ?: return + val toaster = toaster val wallet = walletViewModel ?: return val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle() @@ -128,10 +128,9 @@ fun ChannelDetailScreen( }, onCopyText = { text -> context.setClipboardText(text) - app.toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.common__copied), - description = text, + toaster.success( + title = ToastText(R.string.common__copied), + body = ToastText(text), ) }, onOpenUrl = { txId -> diff --git a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt index 94174c2d6..dd5e4c670 100644 --- a/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/settings/lightning/LightningConnectionsViewModel.kt @@ -31,13 +31,12 @@ import to.bitkit.ext.calculateRemoteBalance import to.bitkit.ext.createChannelDetails import to.bitkit.ext.filterOpen import to.bitkit.ext.filterPending -import to.bitkit.models.Toast import to.bitkit.repositories.ActivityRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -51,6 +50,7 @@ class LightningConnectionsViewModel @Inject constructor( private val logsRepo: LogsRepo, private val walletRepo: WalletRepo, private val activityRepo: ActivityRepo, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(LightningConnectionsUiState()) @@ -338,11 +338,10 @@ class LightningConnectionsViewModel @Inject constructor( viewModelScope.launch { logsRepo.zipLogsForSharing() .onSuccess { uri -> onReady(uri) } - .onFailure { err -> - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.lightning__error_logs), - description = context.getString(R.string.lightning__error_logs_description), + .onFailure { + toaster.warn( + title = R.string.lightning__error_logs, + body = R.string.lightning__error_logs_description, ) } } @@ -454,10 +453,9 @@ class LightningConnectionsViewModel @Inject constructor( onSuccess = { walletRepo.syncNodeAndWallet() - ToastEventBus.send( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.lightning__close_success_title), - description = context.getString(R.string.lightning__close_success_msg), + toaster.success( + title = R.string.lightning__close_success_title, + body = R.string.lightning__close_success_msg, ) _closeConnectionUiState.update { @@ -470,10 +468,9 @@ class LightningConnectionsViewModel @Inject constructor( onFailure = { error -> Logger.error("Failed to close channel", e = error, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.lightning__close_error), - description = context.getString(R.string.lightning__close_error_msg), + toaster.warn( + title = R.string.lightning__close_error, + body = R.string.lightning__close_error_msg, ) _closeConnectionUiState.update { it.copy(isLoading = false) } diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt deleted file mode 100644 index 5613a4265..000000000 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastEventBus.kt +++ /dev/null @@ -1,34 +0,0 @@ -package to.bitkit.ui.shared.toast - -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import to.bitkit.models.Toast - -object ToastEventBus { - private val _events = MutableSharedFlow(extraBufferCapacity = 1) - val events = _events.asSharedFlow() - - suspend fun send( - type: Toast.ToastType, - title: String, - description: String? = null, - autoHide: Boolean = true, - visibilityTime: Long = Toast.VISIBILITY_TIME_DEFAULT, - ) { - _events.emit( - Toast(type, title, description, autoHide, visibilityTime) - ) - } - - suspend fun send(error: Throwable) { - _events.emit( - Toast( - type = Toast.ToastType.ERROR, - title = "Error", - description = error.message ?: "Unknown error", - autoHide = true, - visibilityTime = Toast.VISIBILITY_TIME_DEFAULT, - ) - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt b/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueue.kt similarity index 85% rename from app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt rename to app/src/main/java/to/bitkit/ui/shared/toast/ToastQueue.kt index 94af44aa3..499721a52 100644 --- a/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueueManager.kt +++ b/app/src/main/java/to/bitkit/ui/shared/toast/ToastQueue.kt @@ -1,6 +1,6 @@ package to.bitkit.ui.shared.toast -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -8,15 +8,17 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.async.BaseCoroutineScope import to.bitkit.models.Toast +import kotlin.time.Duration private const val MAX_QUEUE_SIZE = 5 /** - * Manages a queue of toasts to display sequentially. + * A queue for displaying toasts sequentially. * - * This ensures that toasts are shown one at a time without premature cancellation. - * When a toast is displayed, it waits for its full visibility duration before + * Ensures toasts are shown one at a time without premature cancellation. + * When a toast is displayed, it waits for its full duration before * showing the next toast in the queue. * * Features: @@ -26,7 +28,7 @@ private const val MAX_QUEUE_SIZE = 5 * - Auto-advance to next toast on completion * - Max queue size with FIFO overflow handling */ -class ToastQueueManager(private val scope: CoroutineScope) { +class ToastQueue(dispatcher: CoroutineDispatcher) : BaseCoroutineScope(dispatcher) { // Public state exposed to UI private val _currentToast = MutableStateFlow(null) val currentToast: StateFlow = _currentToast.asStateFlow() @@ -81,7 +83,7 @@ class ToastQueueManager(private val scope: CoroutineScope) { if (isPaused && toast != null) { isPaused = false if (toast.autoHide) { - startTimer(toast.visibilityTime) + startTimer(toast.duration) } } } @@ -108,13 +110,13 @@ class ToastQueueManager(private val scope: CoroutineScope) { // Start auto-hide timer if enabled if (nextToast.autoHide) { - startTimer(nextToast.visibilityTime) + startTimer(nextToast.duration) } } - private fun startTimer(duration: Long) { + private fun startTimer(duration: Duration) { cancelTimer() - timerJob = scope.launch { + timerJob = launch { delay(duration) if (!isPaused) { _currentToast.value = null diff --git a/app/src/main/java/to/bitkit/ui/shared/toast/Toaster.kt b/app/src/main/java/to/bitkit/ui/shared/toast/Toaster.kt new file mode 100644 index 000000000..f41feccb6 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/toast/Toaster.kt @@ -0,0 +1,194 @@ +package to.bitkit.ui.shared.toast + +import androidx.annotation.StringRes +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import to.bitkit.R +import to.bitkit.models.Toast +import to.bitkit.models.ToastText +import to.bitkit.models.ToastType +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration + +@Suppress("TooManyFunctions") +@Singleton +class Toaster @Inject constructor() { + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + @Suppress("LongParameterList") + private fun emit( + type: ToastType, + title: ToastText, + body: ToastText? = null, + autoHide: Boolean = true, + duration: Duration = Toast.DURATION_DEFAULT, + testTag: String? = null, + ) { + _events.tryEmit( + Toast( + type = type, + title = title, + body = body, + autoHide = autoHide, + duration = duration, + testTag = testTag, + ) + ) + } + + // region @StringRes overloads + fun success( + @StringRes title: Int, + @StringRes body: Int? = null, + testTag: String? = null, + ) = emit( + ToastType.SUCCESS, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag + ) + + fun info( + @StringRes title: Int, + @StringRes body: Int? = null, + testTag: String? = null, + ) = emit( + ToastType.INFO, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + + fun lightning( + @StringRes title: Int, + @StringRes body: Int? = null, + testTag: String? = null, + ) = emit( + ToastType.LIGHTNING, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag + ) + + fun warn( + @StringRes title: Int, + @StringRes body: Int? = null, + testTag: String? = null, + ) = emit( + ToastType.WARNING, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag + ) + + fun error( + @StringRes title: Int, + @StringRes body: Int? = null, + testTag: String? = null, + ) = emit( + ToastType.ERROR, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + // endregion + + // region ToastText overloads + fun success( + title: ToastText, + body: ToastText? = null, + testTag: String? = null, + ) = emit(ToastType.SUCCESS, title, body, testTag = testTag) + + fun info( + title: ToastText, + body: ToastText? = null, + testTag: String? = null, + ) = emit(ToastType.INFO, title, body, testTag = testTag) + + fun lightning( + title: ToastText, + body: ToastText? = null, + testTag: String? = null, + ) = emit(ToastType.LIGHTNING, title, body, testTag = testTag) + + fun warn( + title: ToastText, + body: ToastText? = null, + testTag: String? = null, + ) = emit(ToastType.WARNING, title, body, testTag = testTag) + + fun error( + title: ToastText, + body: ToastText? = null, + testTag: String? = null, + ) = emit(ToastType.ERROR, title, body, testTag = testTag) + // endregion + + // region String literal overloads + fun success( + title: String, + body: String? = null, + testTag: String? = null, + ) = emit( + ToastType.SUCCESS, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + + fun info( + title: String, + body: String? = null, + testTag: String? = null, + ) = emit( + ToastType.INFO, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + + fun lightning( + title: String, + body: String? = null, + testTag: String? = null, + ) = emit( + ToastType.LIGHTNING, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + + fun warn( + title: String, + body: String? = null, + testTag: String? = null, + ) = emit( + ToastType.WARNING, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + + fun error( + title: String, + body: String? = null, + testTag: String? = null, + ) = emit( + ToastType.ERROR, + ToastText(title), + body?.let { ToastText(it) }, + testTag = testTag, + ) + + fun error(throwable: Throwable) = emit( + type = ToastType.ERROR, + title = ToastText(R.string.common__error), + body = throwable.message?.let { ToastText(it) } + ?: ToastText(R.string.common__error_body), + ) + // endregion +} diff --git a/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt index 0ccaf924b..4d774bf30 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/BackupSheet.kt @@ -97,6 +97,7 @@ fun BackupSheet( uiState = uiState, onRevealClick = viewModel::onRevealMnemonic, onContinueClick = viewModel::onShowMnemonicContinue, + onMnemonicCopied = viewModel::onMnemonicCopied, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 94c9a8cb2..072927b8e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -102,8 +103,8 @@ import to.bitkit.services.AppUpdaterService import to.bitkit.services.MigrationService import to.bitkit.ui.Routes import to.bitkit.ui.components.Sheet -import to.bitkit.ui.shared.toast.ToastEventBus -import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.shared.toast.ToastQueue +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.theme.TRANSITION_SCREEN_MS import to.bitkit.utils.Logger @@ -125,8 +126,9 @@ import kotlin.time.ExperimentalTime class AppViewModel @Inject constructor( connectivityRepo: ConnectivityRepo, healthRepo: HealthRepo, - toastManagerProvider: @JvmSuppressWildcards (CoroutineScope) -> ToastQueueManager, + toastQueueProvider: @JvmSuppressWildcards (CoroutineDispatcher) -> ToastQueue, timedSheetManagerProvider: @JvmSuppressWildcards (CoroutineScope) -> TimedSheetManager, + val toaster: Toaster, @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, @@ -230,9 +232,7 @@ class AppViewModel @Inject constructor( init { viewModelScope.launch { - ToastEventBus.events.collect { - toast(it.type, it.title, it.description, it.autoHide, it.visibilityTime) - } + toaster.events.collect { toastQueue.enqueue(it) } } viewModelScope.launch { // Delays are required for auth check on launch functionality @@ -448,10 +448,9 @@ class AppViewModel @Inject constructor( migrationService.setShowingMigrationLoading(false) delay(MIGRATION_AUTH_RESET_DELAY_MS) resetIsAuthenticatedStateInternal() - toast( - type = Toast.ToastType.ERROR, + toaster.error( title = "Migration Warning", - description = "Migration completed but node restart failed. Please restart the app." + body = "Migration completed but node restart failed. Please restart the app." ) } @@ -532,20 +531,18 @@ class AppViewModel @Inject constructor( activityRepo.insertActivityFromCjit(cjitEntry = cjitEntry, channel = channel) return } - toast( - type = Toast.ToastType.LIGHTNING, - title = context.getString(R.string.lightning__channel_opened_title), - description = context.getString(R.string.lightning__channel_opened_msg), + toaster.lightning( + title = R.string.lightning__channel_opened_title, + body = R.string.lightning__channel_opened_msg, testTag = "SpendingBalanceReadyToast", ) } private suspend fun notifyTransactionRemoved(event: Event.OnchainTransactionEvicted) { if (activityRepo.wasTransactionReplaced(event.txid)) return - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__toast_transaction_removed_title), - description = context.getString(R.string.wallet__toast_transaction_removed_description), + toaster.warn( + title = R.string.wallet__toast_transaction_removed_title, + body = R.string.wallet__toast_transaction_removed_description, testTag = "TransactionRemovedToast", ) } @@ -557,25 +554,27 @@ class AppViewModel @Inject constructor( showTransactionSheet(result.sheet) } - private fun notifyTransactionUnconfirmed() = toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__toast_transaction_unconfirmed_title), - description = context.getString(R.string.wallet__toast_transaction_unconfirmed_description), + private fun notifyTransactionUnconfirmed() = toaster.warn( + title = R.string.wallet__toast_transaction_unconfirmed_title, + body = R.string.wallet__toast_transaction_unconfirmed_description, testTag = "TransactionUnconfirmedToast", ) private suspend fun notifyTransactionReplaced(event: Event.OnchainTransactionReplaced) { val isReceive = activityRepo.isReceivedTransaction(event.txid) - toast( - type = Toast.ToastType.INFO, - title = when (isReceive) { - true -> R.string.wallet__toast_received_transaction_replaced_title - else -> R.string.wallet__toast_transaction_replaced_title - }.let { context.getString(it) }, - description = when (isReceive) { - true -> R.string.wallet__toast_received_transaction_replaced_description - else -> R.string.wallet__toast_transaction_replaced_description - }.let { context.getString(it) }, + toaster.info( + title = context.getString( + when (isReceive) { + true -> R.string.wallet__toast_received_transaction_replaced_title + else -> R.string.wallet__toast_transaction_replaced_title + } + ), + body = context.getString( + when (isReceive) { + true -> R.string.wallet__toast_received_transaction_replaced_description + else -> R.string.wallet__toast_transaction_replaced_description + } + ), testTag = when (isReceive) { true -> "ReceivedTransactionReplacedToast" else -> "TransactionReplacedToast" @@ -583,10 +582,9 @@ class AppViewModel @Inject constructor( ) } - private fun notifyPaymentFailed() = toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__toast_payment_failed_title), - description = context.getString(R.string.wallet__toast_payment_failed_description), + private fun notifyPaymentFailed() = toaster.error( + title = R.string.wallet__toast_payment_failed_title, + body = R.string.wallet__toast_payment_failed_description, testTag = "PaymentFailedToast", ) @@ -774,10 +772,9 @@ class AppViewModel @Inject constructor( if (lnurl is LnurlParams.LnurlPay) { val minSendable = lnurl.data.minSendableSat() if (_sendUiState.value.amount < minSendable) { - toast( - type = Toast.ToastType.ERROR, + toaster.error( title = context.getString(R.string.wallet__lnurl_pay__error_min__title), - description = context.getString(R.string.wallet__lnurl_pay__error_min__description) + body = context.getString(R.string.wallet__lnurl_pay__error_min__description) .replace("{amount}", minSendable.toString()), testTag = "LnurlPayAmountTooLowToast", ) @@ -829,10 +826,9 @@ class AppViewModel @Inject constructor( private fun onPasteClick() { val data = context.getClipboardText()?.trim() if (data.isNullOrBlank()) { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.wallet__send_clipboard_empty_title), - description = context.getString(R.string.wallet__send_clipboard_empty_text), + toaster.warn( + title = R.string.wallet__send_clipboard_empty_title, + body = R.string.wallet__send_clipboard_empty_text, ) return } @@ -874,10 +870,9 @@ class AppViewModel @Inject constructor( is Scanner.Gift -> onScanGift(scan.code, scan.amount) else -> { Logger.warn("Unhandled scan data: $scan", context = TAG) - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan_err_interpret_title), + toaster.warn( + title = R.string.other__scan_err_decoding, + body = R.string.other__scan_err_interpret_title, ) } } @@ -891,10 +886,9 @@ class AppViewModel @Inject constructor( ?.invoice ?.takeIf { invoice -> if (invoice.isExpired) { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__expired), + toaster.error( + title = R.string.other__scan_err_decoding, + body = R.string.other__scan__error__expired, ) Logger.debug( @@ -960,10 +954,9 @@ class AppViewModel @Inject constructor( private suspend fun onScanLightning(invoice: LightningInvoice, scanResult: String) { if (invoice.isExpired) { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__scan_err_decoding), - description = context.getString(R.string.other__scan__error__expired), + toaster.error( + title = R.string.other__scan_err_decoding, + body = R.string.other__scan__error__expired, ) return } @@ -972,10 +965,9 @@ class AppViewModel @Inject constructor( if (quickPayHandled) return if (!lightningRepo.canSend(invoice.amountSatoshis)) { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_insufficient_funds_title), - description = context.getString(R.string.wallet__error_insufficient_funds_msg) + toaster.error( + title = R.string.wallet__error_insufficient_funds_title, + body = R.string.wallet__error_insufficient_funds_msg, ) return } @@ -1016,10 +1008,9 @@ class AppViewModel @Inject constructor( val maxSendable = data.maxSendableSat() if (!lightningRepo.canSend(minSendable)) { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__lnurl_pay_error), - description = context.getString(R.string.other__lnurl_pay_error_no_capacity), + toaster.warn( + title = R.string.other__lnurl_pay_error, + body = R.string.other__lnurl_pay_error_no_capacity, ) return } @@ -1064,10 +1055,9 @@ class AppViewModel @Inject constructor( val maxWithdrawable = data.maxWithdrawableSat() if (minWithdrawable > maxWithdrawable) { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__lnurl_withdr_error), - description = context.getString(R.string.other__lnurl_withdr_error_minmax) + toaster.warn( + title = R.string.other__lnurl_withdr_error, + body = R.string.other__lnurl_withdr_error_minmax, ) return } @@ -1113,17 +1103,15 @@ class AppViewModel @Inject constructor( k1 = k1, domain = domain, ).onFailure { - toast( - type = Toast.ToastType.WARNING, + toaster.warn( title = context.getString(R.string.other__lnurl_auth_error), - description = context.getString(R.string.other__lnurl_auth_error_msg) + body = context.getString(R.string.other__lnurl_auth_error_msg) .replace("{raw}", it.message?.takeIf { m -> m.isNotBlank() } ?: it.javaClass.simpleName), ) }.onSuccess { - toast( - type = Toast.ToastType.SUCCESS, + toaster.success( title = context.getString(R.string.other__lnurl_auth_success_title), - description = when (domain.isNotBlank()) { + body = when (domain.isNotBlank()) { true -> context.getString(R.string.other__lnurl_auth_success_msg_domain) .replace("{domain}", domain) @@ -1153,9 +1141,9 @@ class AppViewModel @Inject constructor( // val appNetwork = Env.network.toCoreNetworkType() // if (network != appNetwork) { // toast( - // type = Toast.ToastType.WARNING, + // type = ToastType.WARNING, // title = context.getString(R.string.other__qr_error_network_header), - // description = context.getString(R.string.other__qr_error_network_text) + // body = context.getString(R.string.other__qr_error_network_text) // .replace("{selectedNetwork}", appNetwork.name) // .replace("{dataNetwork}", network.name), // ) @@ -1322,7 +1310,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) + toaster.error(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) hideSheet() return } @@ -1335,7 +1323,7 @@ class AppViewModel @Inject constructor( val validatedAddress = runCatching { validateBitcoinAddress(address) } .getOrElse { e -> Logger.error("Invalid bitcoin send address: '$address'", e, context = TAG) - toast(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address))) + toaster.error(Exception(context.getString(R.string.wallet__error_invalid_bitcoin_address))) hideSheet() return } @@ -1357,10 +1345,9 @@ class AppViewModel @Inject constructor( activityRepo.syncActivities() }.onFailure { e -> Logger.error(msg = "Error sending onchain payment", e = e, context = TAG) - toast( - type = Toast.ToastType.ERROR, + toaster.error( title = context.getString(R.string.wallet__error_sending_title), - description = e.message ?: context.getString(R.string.common__error_body) + body = e.message ?: context.getString(R.string.common__error_body), ) hideSheet() } @@ -1408,7 +1395,7 @@ class AppViewModel @Inject constructor( preActivityMetadataRepo.deletePreActivityMetadata(createdMetadataPaymentId) } Logger.error("Error sending lightning payment", e, context = TAG) - toast(e) + toaster.error(e) hideSheet() } } @@ -1449,10 +1436,9 @@ class AppViewModel @Inject constructor( callback = lnurl.data.callback, paymentRequest = invoice ).onSuccess { - toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.other__lnurl_withdr_success_title), - description = context.getString(R.string.other__lnurl_withdr_success_msg), + toaster.success( + title = R.string.other__lnurl_withdr_success_title, + body = R.string.other__lnurl_withdr_success_msg, ) hideSheet() _sendUiState.update { it.copy(isLoading = false) } @@ -1482,7 +1468,7 @@ class AppViewModel @Inject constructor( mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) - toast(e) + toaster.error(e) _transactionSheet.update { it.copy(isLoadingDetails = false) } } } @@ -1506,7 +1492,7 @@ class AppViewModel @Inject constructor( mainScreenEffect(MainScreenEffect.Navigate(nextRoute)) }.onFailure { e -> Logger.error(msg = "Activity not found", context = TAG) - toast(e) + toaster.error(e) _successSendUiState.update { it.copy(isLoadingDetails = false) } } } @@ -1789,52 +1775,14 @@ class AppViewModel @Inject constructor( // endregion // region Toasts - private val toastManager = toastManagerProvider(viewModelScope) - val currentToast: StateFlow = toastManager.currentToast - - fun toast( - type: Toast.ToastType, - title: String, - description: String? = null, - autoHide: Boolean = true, - visibilityTime: Long = Toast.VISIBILITY_TIME_DEFAULT, - testTag: String? = null, - ) { - toastManager.enqueue( - Toast( - type = type, - title = title, - description = description, - autoHide = autoHide, - visibilityTime = visibilityTime, - testTag = testTag, - ) - ) - } - - fun toast(error: Throwable) { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = error.message ?: context.getString(R.string.common__error_body) - ) - } - - fun toast(toast: Toast) { - toast( - type = toast.type, - title = toast.title, - description = toast.description, - autoHide = toast.autoHide, - visibilityTime = toast.visibilityTime - ) - } + private val toastQueue = toastQueueProvider(Dispatchers.Main.immediate) + val currentToast: StateFlow = toastQueue.currentToast - fun hideToast() = toastManager.dismissCurrentToast() + fun hideToast() = toastQueue.dismissCurrentToast() - fun pauseToast() = toastManager.pauseCurrentToast() + fun pauseToast() = toastQueue.pauseCurrentToast() - fun resumeToast() = toastManager.resumeCurrentToast() + fun resumeToast() = toastQueue.resumeCurrentToast() // endregion // region security @@ -1866,10 +1814,9 @@ class AppViewModel @Inject constructor( keychain.upsertString(Keychain.Key.PIN_ATTEMPTS_REMAINING.name, newAttempts.toString()) if (newAttempts <= 0) { - toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.security__wiped_title), - description = context.getString(R.string.security__wiped_message), + toaster.success( + title = R.string.security__wiped_title, + body = R.string.security__wiped_message, ) delay(250) // small delay for UI feedback mainScreenEffect(MainScreenEffect.WipeWallet) diff --git a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt index 30a5bd849..3e8733923 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -18,13 +18,12 @@ import to.bitkit.env.Env import to.bitkit.models.NewTransactionSheetDetails import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType -import to.bitkit.models.Toast import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.LogsRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -41,29 +40,26 @@ class DevSettingsViewModel @Inject constructor( private val cacheStore: CacheStore, private val blocktankRepo: BlocktankRepo, private val appDb: AppDb, + private val toaster: Toaster, ) : ViewModel() { fun openChannel() = viewModelScope.launch { val peer = lightningRepo.getPeers()?.firstOrNull() if (peer == null) { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "No peer connected") + toaster.warn("No peer connected") return@launch } lightningRepo.openChannel(peer, 50_000u, 25_000u) - .onSuccess { - ToastEventBus.send(type = Toast.ToastType.INFO, title = "Channel pending") - } - .onFailure { ToastEventBus.send(it) } + .onSuccess { toaster.info("Channel pending") } + .onFailure { toaster.error(it) } } fun registerForNotifications() = viewModelScope.launch { lightningRepo.registerForNotifications() - .onSuccess { - ToastEventBus.send(type = Toast.ToastType.INFO, title = "Registered for notifications") - } - .onFailure { ToastEventBus.send(it) } + .onSuccess { toaster.info("Registered for notifications") } + .onFailure { toaster.error(it) } } fun testLspNotification() = viewModelScope.launch { @@ -74,9 +70,9 @@ class DevSettingsViewModel @Inject constructor( notificationType = "incomingHtlc", customUrl = Env.blocktankNotificationApiUrl, ) - ToastEventBus.send(type = Toast.ToastType.INFO, title = "LSP notification sent to this device") + toaster.info("LSP notification sent to this device") }.onFailure { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") + toaster.warn("Error testing LSP notification") } } @@ -103,10 +99,9 @@ class DevSettingsViewModel @Inject constructor( logsRepo.zipLogsForSharing() .onSuccess { uri -> onReady(uri) } .onFailure { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.lightning__error_logs), - description = context.getString(R.string.lightning__error_logs_description), + toaster.warn( + context.getString(R.string.lightning__error_logs), + context.getString(R.string.lightning__error_logs_description), ) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt index d8aced251..3121e8577 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt @@ -17,10 +17,9 @@ import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.data.backup.VssBackupClient import to.bitkit.di.BgDispatcher import to.bitkit.ext.of -import to.bitkit.models.Toast import to.bitkit.repositories.LightningRepo import to.bitkit.services.NetworkGraphInfo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import java.io.File import javax.inject.Inject @@ -31,6 +30,7 @@ class LdkDebugViewModel @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val lightningRepo: LightningRepo, private val vssBackupClient: VssBackupClient, + private val toaster: Toaster, ) : ViewModel() { private val _uiState = MutableStateFlow(LdkDebugUiState()) @@ -43,12 +43,7 @@ class LdkDebugViewModel @Inject constructor( fun addPeer() { val uri = _uiState.value.nodeUri.trim() if (uri.isEmpty()) { - viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Please enter a node URI", - ) - } + viewModelScope.launch { toaster.warn("Please enter a node URI") } return } connectPeer(uri) @@ -60,12 +55,7 @@ class LdkDebugViewModel @Inject constructor( val pastedUri = clipData?.getItemAt(0)?.text?.toString()?.trim() if (pastedUri.isNullOrEmpty()) { - viewModelScope.launch { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Clipboard is empty", - ) - } + viewModelScope.launch { toaster.warn("Clipboard is empty") } return } @@ -81,26 +71,15 @@ class LdkDebugViewModel @Inject constructor( lightningRepo.connectPeer(peer) }.onSuccess { result -> result.onSuccess { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Peer connected", - ) + toaster.info("Peer connected") _uiState.update { it.copy(nodeUri = "") } }.onFailure { e -> Logger.error("Failed to connect peer", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Failed to connect peer", - description = e.message, - ) + toaster.error("Failed to connect peer", e.message) } }.onFailure { e -> Logger.error("Failed to parse peer URI", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Invalid node URI format", - description = e.message, - ) + toaster.error("Invalid node URI format", e.message) } _uiState.update { it.copy(isLoading = false) } } @@ -118,15 +97,9 @@ class LdkDebugViewModel @Inject constructor( context = TAG ) _uiState.update { it.copy(networkGraphInfo = info) } - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Network graph info logged", - ) + toaster.info("Network graph info logged") } else { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Failed to get network graph info", - ) + toaster.warn("Failed to get network graph info") } } } @@ -137,18 +110,11 @@ class LdkDebugViewModel @Inject constructor( val outputDir = context.cacheDir.absolutePath lightningRepo.exportNetworkGraphToFile(outputDir).onSuccess { file -> Logger.info("Network graph exported to: ${file.absolutePath}", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Network graph exported", - ) + toaster.info("Network graph exported") onFileReady(file) }.onFailure { e -> Logger.error("Failed to export network graph", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Failed to export network graph", - description = e.message, - ) + toaster.error("Failed to export network graph", e.message) } _uiState.update { it.copy(isLoading = false) } } @@ -160,17 +126,10 @@ class LdkDebugViewModel @Inject constructor( vssBackupClient.listKeys().onSuccess { keys -> Logger.info("VSS keys: ${keys.size}", context = TAG) _uiState.update { it.copy(vssKeys = keys) } - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Found ${keys.size} VSS key(s)", - ) + toaster.info("Found ${keys.size} VSS key(s)") }.onFailure { e -> Logger.error("Failed to list VSS keys", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Failed to list VSS keys", - description = e.message, - ) + toaster.error("Failed to list VSS keys", e.message) } _uiState.update { it.copy(isLoading = false) } } @@ -182,17 +141,10 @@ class LdkDebugViewModel @Inject constructor( vssBackupClient.deleteAllKeys().onSuccess { deletedCount -> Logger.info("Deleted $deletedCount VSS keys", context = TAG) _uiState.update { it.copy(vssKeys = emptyList()) } - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Deleted $deletedCount VSS key(s)", - ) + toaster.info("Deleted $deletedCount VSS key(s)") }.onFailure { e -> Logger.error("Failed to delete VSS keys", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Failed to delete VSS keys", - description = e.message, - ) + toaster.error("Failed to delete VSS keys", e.message) } _uiState.update { it.copy(isLoading = false) } } @@ -208,24 +160,14 @@ class LdkDebugViewModel @Inject constructor( _uiState.update { state -> state.copy(vssKeys = state.vssKeys.filter { it.key != key }) } - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Deleted key: $key", - ) + toaster.info("Deleted key: $key") } else { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Key not found: $key", - ) + toaster.warn("Key not found: $key") } } .onFailure { e -> Logger.error("Failed to delete VSS key: $key", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Failed to delete key", - description = e.message, - ) + toaster.error("Failed to delete key", e.message) } _uiState.update { it.copy(isLoading = false) } } @@ -237,18 +179,11 @@ class LdkDebugViewModel @Inject constructor( lightningRepo.restartNode() .onSuccess { Logger.info("Node restarted successfully", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Node restarted", - ) + toaster.info("Node restarted") } .onFailure { e -> Logger.error("Failed to restart node", e, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Failed to restart node", - description = e.message, - ) + toaster.error("Failed to restart node", e.message) } _uiState.update { it.copy(isLoading = false) } } diff --git a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt index 9a83e0472..996260567 100644 --- a/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/TransferViewModel.kt @@ -29,7 +29,7 @@ import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.ext.amountOnClose -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.models.TransactionSpeed import to.bitkit.models.TransferType import to.bitkit.models.safe @@ -37,7 +37,7 @@ import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.TransferRepo import to.bitkit.repositories.WalletRepo -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject import kotlin.math.min @@ -62,6 +62,7 @@ class TransferViewModel @Inject constructor( private val cacheStore: CacheStore, private val transferRepo: TransferRepo, private val clock: Clock, + private val toaster: Toaster, ) : ViewModel() { private val _spendingUiState = MutableStateFlow(TransferToSpendingUiState()) val spendingUiState = _spendingUiState.asStateFlow() @@ -93,7 +94,7 @@ class TransferViewModel @Inject constructor( setTransferEffect( TransferEffect.ToastError( title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString( + body = context.getString( R.string.lightning__spending_amount__error_max__description_zero ), ) @@ -213,7 +214,7 @@ class TransferViewModel @Inject constructor( launch { watchOrder(order.id) } } .onFailure { error -> - ToastEventBus.send(error) + toaster.error(error) } } } @@ -458,10 +459,9 @@ class TransferViewModel @Inject constructor( if (nonTrustedChannels.isEmpty()) { channelsToClose = emptyList() Logger.error("Cannot force close channels with trusted peer", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__force_failed_title), - description = context.getString(R.string.lightning__force_failed_msg) + toaster.error( + title = R.string.lightning__force_failed_title, + body = R.string.lightning__force_failed_msg, ) return@runCatching } @@ -482,26 +482,23 @@ class TransferViewModel @Inject constructor( Logger.info("Force close initiated successfully for all channels", context = TAG) val initMsg = context.getString(R.string.lightning__force_init_msg) val skippedMsg = context.getString(R.string.lightning__force_channels_skipped) - val description = if (trustedChannels.isNotEmpty()) "$initMsg $skippedMsg" else initMsg - ToastEventBus.send( - type = Toast.ToastType.LIGHTNING, - title = context.getString(R.string.lightning__force_init_title), - description = description, + val bodyText = if (trustedChannels.isNotEmpty()) "$initMsg $skippedMsg" else initMsg + toaster.lightning( + title = ToastText(R.string.lightning__force_init_title), + body = ToastText(bodyText), ) } else { Logger.error("Force close failed for ${failedChannels.size} channels", context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__force_failed_title), - description = context.getString(R.string.lightning__force_failed_msg) + toaster.error( + title = R.string.lightning__force_failed_title, + body = R.string.lightning__force_failed_msg, ) } }.onFailure { Logger.error("Force close failed", e = it, context = TAG) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.lightning__force_failed_title), - description = context.getString(R.string.lightning__force_failed_msg) + toaster.error( + title = R.string.lightning__force_failed_title, + body = R.string.lightning__force_failed_msg, ) } _isForceTransferLoading.value = false @@ -570,6 +567,6 @@ data class TransferValues( sealed interface TransferEffect { data object OnOrderCreated : TransferEffect data class ToastException(val e: Throwable) : TransferEffect - data class ToastError(val title: String, val description: String) : TransferEffect + data class ToastError(val title: String, val body: String) : TransferEffect } // endregion diff --git a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt index 471fce9d5..76dbcb3cf 100644 --- a/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt @@ -26,7 +26,7 @@ import org.lightningdevkit.ldknode.PeerDetails import to.bitkit.R import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher -import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.repositories.BackupRepo import to.bitkit.repositories.BlocktankRepo import to.bitkit.repositories.LightningRepo @@ -35,7 +35,7 @@ import to.bitkit.repositories.SyncSource import to.bitkit.repositories.WalletRepo import to.bitkit.services.MigrationService import to.bitkit.ui.onboarding.LOADING_MS -import to.bitkit.ui.shared.toast.ToastEventBus +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import to.bitkit.utils.isTxSyncTimeout import javax.inject.Inject @@ -54,6 +54,7 @@ class WalletViewModel @Inject constructor( private val backupRepo: BackupRepo, private val blocktankRepo: BlocktankRepo, private val migrationService: MigrationService, + private val toaster: Toaster, ) : ViewModel() { companion object { private const val TAG = "WalletViewModel" @@ -126,10 +127,9 @@ class WalletViewModel @Inject constructor( Logger.error("RN migration failed", it, context = TAG) migrationService.markMigrationChecked() migrationService.setShowingMigrationLoading(false) - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Migration Failed", - description = "Please restore your wallet manually using your recovery phrase" + toaster.error( + title = R.string.wallet__migration_error_title, + body = R.string.wallet__migration_error_body, ) } } @@ -255,7 +255,7 @@ class WalletViewModel @Inject constructor( .onFailure { Logger.error("Node startup error", it, context = TAG) if (it !is RecoveryModeError) { - ToastEventBus.send(it) + toaster.error(it) } } } @@ -267,7 +267,7 @@ class WalletViewModel @Inject constructor( lightningRepo.stop() .onFailure { Logger.error("Node stop error", it) - ToastEventBus.send(it) + toaster.error(it) } } } @@ -277,7 +277,7 @@ class WalletViewModel @Inject constructor( .onFailure { Logger.error("Failed to refresh state: ${it.message}", it) if (it is CancellationException || it.isTxSyncTimeout()) return@onFailure - ToastEventBus.send(it) + toaster.error(it) } } @@ -301,17 +301,15 @@ class WalletViewModel @Inject constructor( viewModelScope.launch { lightningRepo.disconnectPeer(peer) .onSuccess { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = context.getString(R.string.common__success), - description = context.getString(R.string.wallet__peer_disconnected) + toaster.info( + title = R.string.common__success, + body = R.string.wallet__peer_disconnected, ) } .onFailure { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.common__error), - description = it.message ?: context.getString(R.string.common__error_body) + toaster.error( + title = ToastText(R.string.common__error), + body = ToastText(it.message ?: context.getString(R.string.common__error_body)), ) } } @@ -319,10 +317,9 @@ class WalletViewModel @Inject constructor( fun updateBip21Invoice(amountSats: ULong? = walletState.value.bip21AmountSats) = viewModelScope.launch { walletRepo.updateBip21Invoice(amountSats).onFailure { error -> - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__error_invoice_update), - description = error.message ?: context.getString(R.string.common__error_body) + toaster.error( + title = ToastText(R.string.wallet__error_invoice_update), + body = ToastText(error.message ?: context.getString(R.string.common__error_body)), ) } } @@ -335,7 +332,7 @@ class WalletViewModel @Inject constructor( fun wipeWallet() = viewModelScope.launch(bgDispatcher) { walletRepo.wipeWallet().onFailure { - ToastEventBus.send(it) + toaster.error(it) } } @@ -346,7 +343,7 @@ class WalletViewModel @Inject constructor( backupRepo.scheduleFullBackup() } .onFailure { - ToastEventBus.send(it) + toaster.error(it) } } @@ -358,7 +355,7 @@ class WalletViewModel @Inject constructor( mnemonic = mnemonic, bip39Passphrase = bip39Passphrase, ).onFailure { - ToastEventBus.send(it) + toaster.error(it) } } @@ -366,13 +363,13 @@ class WalletViewModel @Inject constructor( fun addTagToSelected(newTag: String) = viewModelScope.launch { walletRepo.addTagToSelected(newTag).onFailure { - ToastEventBus.send(it) + toaster.error(it) } } fun removeTag(tag: String) = viewModelScope.launch { walletRepo.removeTag(tag).onFailure { - ToastEventBus.send(it) + toaster.error(it) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54f5d0b3c..415efb3db 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1094,6 +1094,7 @@ Please check your transaction info and try again. No transaction is available to broadcast. Error Sending + Failed to load UTXOs: {raw} Apply Clear Select Range @@ -1112,6 +1113,8 @@ Withdraw Bitcoin Fee Exceeds Maximum Limit Lower the custom fee and try again. + Please restore your wallet manually using your recovery phrase + Migration Failed Fee Below Minimum Limit Increase the custom fee and try again. MINIMUM diff --git a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt index 8cbdcdf19..3a247f567 100644 --- a/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/CurrencyRepoTest.kt @@ -17,6 +17,7 @@ import to.bitkit.models.FxRate import to.bitkit.models.PrimaryDisplay import to.bitkit.services.CurrencyService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.Toaster import java.math.BigDecimal import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -33,6 +34,7 @@ class CurrencyRepoTest : BaseUnitTest() { private val settingsStore = mock() private val cacheStore = mock() private val clock = mock() + private val toaster = mock() private lateinit var sut: CurrencyRepo @@ -75,6 +77,7 @@ class CurrencyRepoTest : BaseUnitTest() { currencyService = currencyService, settingsStore = settingsStore, cacheStore = cacheStore, + toaster = toaster, enablePolling = false, clock = clock ) diff --git a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt index e80b3f74b..5b6c73a5b 100644 --- a/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/WalletViewModelTest.kt @@ -27,6 +27,7 @@ import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WalletState import to.bitkit.services.MigrationService import to.bitkit.test.BaseUnitTest +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.viewmodels.RestoreState import to.bitkit.viewmodels.WalletViewModel @@ -41,6 +42,7 @@ class WalletViewModelTest : BaseUnitTest() { private val backupRepo = mock() private val blocktankRepo = mock() private val migrationService = mock() + private val toaster = mock() private val lightningState = MutableStateFlow(LightningState()) private val walletState = MutableStateFlow(WalletState()) @@ -63,6 +65,7 @@ class WalletViewModelTest : BaseUnitTest() { backupRepo = backupRepo, blocktankRepo = blocktankRepo, migrationService = migrationService, + toaster = toaster, ) } @@ -247,6 +250,7 @@ class WalletViewModelTest : BaseUnitTest() { backupRepo = backupRepo, blocktankRepo = blocktankRepo, migrationService = migrationService, + toaster = toaster, ) assertEquals(RestoreState.Initial, testSut.restoreState.value) @@ -287,6 +291,7 @@ class WalletViewModelTest : BaseUnitTest() { backupRepo = backupRepo, blocktankRepo = blocktankRepo, migrationService = migrationService, + toaster = toaster, ) // Trigger restore to put state in non-idle diff --git a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt index 703926a78..36597e925 100644 --- a/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/wallets/send/SendFeeViewModelTest.kt @@ -17,6 +17,7 @@ import to.bitkit.repositories.LightningRepo import to.bitkit.repositories.WalletRepo import to.bitkit.test.BaseUnitTest import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.viewmodels.SendUiState import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -28,6 +29,7 @@ class SendFeeViewModelTest : BaseUnitTest() { private val currencyRepo: CurrencyRepo = mock() private val walletRepo: WalletRepo = mock() private val context: Context = mock() + private val toaster: Toaster = mock() private val balance = 100_000uL private val fee = 1_000uL @@ -42,7 +44,7 @@ class SendFeeViewModelTest : BaseUnitTest() { whenever(walletRepo.balanceState) .thenReturn(MutableStateFlow(BalanceState(totalOnchainSats = balance))) - sut = SendFeeViewModel(lightningRepo, currencyRepo, walletRepo, context) + sut = SendFeeViewModel(lightningRepo, currencyRepo, walletRepo, context, toaster) } @Test diff --git a/app/src/test/java/to/bitkit/ui/shared/toast/ToastQueueTest.kt b/app/src/test/java/to/bitkit/ui/shared/toast/ToastQueueTest.kt new file mode 100644 index 000000000..f341814ec --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/shared/toast/ToastQueueTest.kt @@ -0,0 +1,150 @@ +package to.bitkit.ui.shared.toast + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import to.bitkit.models.Toast +import to.bitkit.models.ToastText +import to.bitkit.models.ToastType +import to.bitkit.test.BaseUnitTest +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class ToastQueueTest : BaseUnitTest(StandardTestDispatcher()) { + private lateinit var sut: ToastQueue + + @Before + fun setUp() { + sut = ToastQueue(testDispatcher) + } + + @Test + fun `enqueue shows toast immediately when queue empty`() = test { + val toast = createToast() + + sut.enqueue(toast) + + assertEquals(toast, sut.currentToast.value) + } + + @Test + fun `enqueue queues toast when another is displayed`() = test { + val toast1 = createToast(title = "First") + val toast2 = createToast(title = "Second") + + sut.enqueue(toast1) + sut.enqueue(toast2) + + assertEquals(ToastText.Literal("Second"), sut.currentToast.value?.title) + } + + @Test + fun `dismiss advances to next toast in queue`() = test { + val toast1 = createToast(title = "First", autoHide = false) + val toast2 = createToast(title = "Second", autoHide = false) + + sut.enqueue(toast1) + sut.enqueue(toast2) + + assertEquals(ToastText.Literal("Second"), sut.currentToast.value?.title) + + sut.dismissCurrentToast() + + assertNull(sut.currentToast.value) + } + + @Test + fun `auto-hide timer dismisses toast after duration`() = test { + val toast = createToast(autoHide = true) + + sut.enqueue(toast) + + assertEquals(toast, sut.currentToast.value) + + advanceTimeBy(3001) + + assertNull(sut.currentToast.value) + } + + @Test + fun `pause stops auto-hide timer`() = test { + val toast = createToast(autoHide = true) + + sut.enqueue(toast) + advanceTimeBy(1000) + sut.pauseCurrentToast() + advanceTimeBy(5000) + + assertEquals(toast, sut.currentToast.value) + } + + @Test + fun `resume restarts auto-hide timer`() = test { + val toast = createToast(autoHide = true) + + sut.enqueue(toast) + advanceTimeBy(1000) + sut.pauseCurrentToast() + advanceTimeBy(5000) + sut.resumeCurrentToast() + advanceTimeBy(2000) + + assertEquals(toast, sut.currentToast.value) + + advanceTimeBy(1001) + + assertNull(sut.currentToast.value) + } + + @Test + fun `max queue size drops oldest when exceeded`() = test { + val toasts = (1..6).map { createToast(title = "Toast $it") } + + toasts.forEach { sut.enqueue(it) } + + assertEquals(ToastText.Literal("Toast 6"), sut.currentToast.value?.title) + } + + @Test + fun `clear removes all toasts and hides current`() = test { + val toast1 = createToast(title = "First", autoHide = false) + val toast2 = createToast(title = "Second", autoHide = false) + + sut.enqueue(toast1) + sut.enqueue(toast2) + sut.clear() + + assertNull(sut.currentToast.value) + } + + @Test + fun `non-auto-hide toast stays until dismissed`() = test { + val toast = createToast(autoHide = false) + + sut.enqueue(toast) + advanceTimeBy(10_000) + + assertEquals(toast, sut.currentToast.value) + + sut.dismissCurrentToast() + + assertNull(sut.currentToast.value) + } + + private fun createToast( + title: String = "Test Toast", + body: String? = null, + type: ToastType = ToastType.INFO, + autoHide: Boolean = true, + ) = Toast( + type = type, + title = ToastText.Literal(title), + body = body?.let { ToastText.Literal(it) }, + autoHide = autoHide, + duration = 3.seconds, + ) +} diff --git a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt index e36008926..04b7d9502 100644 --- a/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt +++ b/app/src/test/java/to/bitkit/viewmodels/AmountInputViewModelTest.kt @@ -28,6 +28,7 @@ import to.bitkit.ui.components.KEY_000 import to.bitkit.ui.components.KEY_DECIMAL import to.bitkit.ui.components.KEY_DELETE import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.shared.toast.Toaster import kotlin.time.Clock import kotlin.time.Duration.Companion.milliseconds import kotlin.time.ExperimentalTime @@ -42,6 +43,7 @@ class AmountInputViewModelTest : BaseUnitTest() { private val settingsStore = mock() private val cacheStore = mock() private val clock = mock() + private val toaster = mock() @Suppress("SpellCheckingInspection") private val testRates = listOf( @@ -68,6 +70,7 @@ class AmountInputViewModelTest : BaseUnitTest() { currencyService = currencyService, settingsStore = settingsStore, cacheStore = cacheStore, + toaster = toaster, enablePolling = false, clock = clock, ) @@ -819,6 +822,7 @@ class AmountInputViewModelTest : BaseUnitTest() { currencyService = currencyService, settingsStore = settingsStore, cacheStore = cacheStore, + toaster = toaster, enablePolling = false, clock = clock, )