diff --git a/AGENTS.md b/AGENTS.md index e7a0b0da0..d17df7136 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,10 +206,12 @@ suspend fun getData(): Result = withContext(Dispatchers.IO) { - ALWAYS add new localizable string string resources in alphabetical order in `strings.xml` - NEVER add string resources for strings used only in dev settings screens and previews and never localize acronyms - ALWAYS use template in `.github/pull_request_template.md` for PR descriptions +- ALWAYS review PR description after new pushes to acknowledge what changes the latest pushes warrant and update it accordingly - ALWAYS wrap `ULong` numbers with `USat` in arithmetic operations, to guard against overflows - 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..e01b69a03 100644 --- a/app/src/main/java/to/bitkit/di/ViewModelModule.kt +++ b/app/src/main/java/to/bitkit/di/ViewModelModule.kt @@ -6,7 +6,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineScope -import to.bitkit.ui.shared.toast.ToastQueueManager +import to.bitkit.ui.shared.toast.ToastQueue import javax.inject.Singleton @Module @@ -14,12 +14,8 @@ import javax.inject.Singleton object ViewModelModule { @Singleton @Provides - fun provideFirebaseMessaging(): FirebaseMessaging { - return FirebaseMessaging.getInstance() - } + fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance() @Provides - fun provideToastManagerProvider(): (CoroutineScope) -> ToastQueueManager { - return ::ToastQueueManager - } + fun provideToastQueueProvider(): (CoroutineScope) -> ToastQueue = ::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..3d7ee25c1 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 autoHide: Boolean, - val visibilityTime: Long = VISIBILITY_TIME_DEFAULT, + val title: ToastText, + val body: ToastText? = null, + val autoHide: Boolean = true, + 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..74abb429b --- /dev/null +++ b/app/src/main/java/to/bitkit/models/ToastText.kt @@ -0,0 +1,47 @@ +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 + + data class Parameterized( + @StringRes val resId: Int, + val params: Map, + ) : 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) + operator fun invoke( + @StringRes resId: Int, + params: Map, + ): ToastText = Parameterized(resId, params) + } +} + +@Composable +fun ToastText.asString(): String = when (this) { + is ToastText.Resource -> stringResource(resId) + is ToastText.Parameterized -> params.entries.fold(stringResource(resId)) { acc, (key, value) -> + acc.replace("{$key}", value) + } + is ToastText.Literal -> value +} + +fun ToastText.asString(context: Context): String = when (this) { + is ToastText.Resource -> context.getString(resId) + is ToastText.Parameterized -> params.entries.fold(context.getString(resId)) { acc, (key, value) -> + acc.replace("{$key}", value) + } + 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..be8472e31 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -34,7 +34,6 @@ import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.resetPin import to.bitkit.di.IoDispatcher import to.bitkit.di.json -import to.bitkit.ext.formatPlural import to.bitkit.ext.nowMillis import to.bitkit.models.ActivityBackupV1 import to.bitkit.models.BackupCategory @@ -42,11 +41,11 @@ 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.ToastText 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,11 +373,11 @@ class BackupRepo @Inject constructor( lastNotificationTime = currentTime scope.launch { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.settings__backup__failed_title), - description = context.getString(R.string.settings__backup__failed_message).formatPlural( - mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS)) + toaster.error( + title = ToastText(R.string.settings__backup__failed_title), + body = ToastText( + R.string.settings__backup__failed_message, + mapOf("interval" to (BACKUP_CHECK_INTERVAL / MINUTE_IN_MS).toString()) ), ) } diff --git a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt index 57d347abc..4e474b35f 100644 --- a/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import to.bitkit.R import to.bitkit.data.CacheStore import to.bitkit.data.SettingsStore import to.bitkit.di.BgDispatcher @@ -29,11 +30,11 @@ 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.ToastText 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 @@ -44,7 +45,7 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime @OptIn(ExperimentalTime::class) -@Suppress("TooManyFunctions") +@Suppress("TooManyFunctions", "LongParameterList") @Singleton class CurrencyRepo @Inject constructor( @BgDispatcher private val bgDispatcher: CoroutineDispatcher, @@ -53,6 +54,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 +94,9 @@ class CurrencyRepo @Inject constructor( .distinctUntilChanged() .collect { isStale -> if (isStale) { - ToastEventBus.send( - type = Toast.ToastType.ERROR, - title = "Rates currently unavailable", - description = "An error has occurred. Please try again later." + toaster.error( + title = ToastText(R.string.currency__rates_error_title), + body = ToastText(R.string.currency__rates_error_body), ) } } @@ -107,18 +108,18 @@ class CurrencyRepo @Inject constructor( combine( settingsStore.data.distinctUntilChanged(), cacheStore.data.distinctUntilChanged() - ) { settings, cachedData -> - val selectedRate = cachedData.cachedRates.firstOrNull { rate -> + ) { settings, cache -> + val selectedRate = cache.cachedRates.firstOrNull { rate -> rate.quote == settings.selectedCurrency } _currencyState.value.copy( - rates = cachedData.cachedRates, + rates = cache.cachedRates, selectedCurrency = settings.selectedCurrency, displayUnit = settings.displayUnit, primaryDisplay = settings.primaryDisplay, currencySymbol = selectedRate?.currencySymbol ?: "$", error = null, - hasStaleData = false + hasStaleData = false, ) }.collect { newState -> _currencyState.update { newState } diff --git a/app/src/main/java/to/bitkit/services/CoreService.kt b/app/src/main/java/to/bitkit/services/CoreService.kt index 25abc125a..ad42d88ed 100644 --- a/app/src/main/java/to/bitkit/services/CoreService.kt +++ b/app/src/main/java/to/bitkit/services/CoreService.kt @@ -629,6 +629,7 @@ class ActivityService( ) } + @Suppress("CyclomaticComplexMethod") private suspend fun processOnchainPayment( kind: PaymentKind.Onchain, payment: PaymentDetails, @@ -646,12 +647,7 @@ class ActivityService( } } - if (existingActivity != null && - existingActivity is Activity.Onchain && - ((existingActivity as Activity.Onchain).v1.updatedAt ?: 0u) > payment.latestUpdateTimestamp - ) { - return - } + if (((existingActivity as? Activity.Onchain)?.v1?.updatedAt ?: 0u) > payment.latestUpdateTimestamp) return var resolvedChannelId = channelId @@ -672,7 +668,7 @@ class ActivityService( val ldkValue = payment.amountSats ?: 0u val onChain = if (existingActivity is Activity.Onchain) { buildUpdatedOnchainActivity( - existingActivity = existingActivity as Activity.Onchain, + existingActivity = existingActivity, confirmationData = confirmationData, ldkValue = ldkValue, channelId = resolvedChannelId, @@ -692,9 +688,8 @@ class ActivityService( return } - if (existingActivity != null && existingActivity is Activity.Onchain) { - val existingOnchain = existingActivity.v1 - updateActivity(existingOnchain.id, Activity.Onchain(onChain)) + if (existingActivity is Activity.Onchain) { + updateActivity(existingActivity.v1.id, Activity.Onchain(onChain)) } else { upsertActivity(Activity.Onchain(onChain)) } diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 068b4c5a8..9e6b4f013 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -1394,7 +1394,7 @@ class MigrationService @Inject constructor( } } - @Suppress("CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "LongMethod") private suspend fun updateOnchainActivityMetadata( item: RNActivityItem, onchain: OnchainActivity, @@ -1443,9 +1443,9 @@ class MigrationService @Inject constructor( wasUpdated = true } - item.feeRate?.let { feeRate -> - if (feeRate > 0 && updated.feeRate != feeRate.toULong()) { - updated = updated.copy(feeRate = feeRate.toULong()) + item.feeRate?.toULong()?.let { feeRate -> + if (feeRate > 0u && updated.feeRate != feeRate) { + updated = updated.copy(feeRate = feeRate) wasUpdated = true } } @@ -1487,12 +1487,8 @@ class MigrationService @Inject constructor( updateOnchainActivityMetadata(item, onchain)?.let { updated -> activityRepo.updateActivity(updated.id, Activity.Onchain(updated)) .onSuccess { updatedCount++ } - .onFailure { e -> - Logger.error( - "Failed to update onchain activity metadata for $txId: $e", - e, - context = TAG - ) + .onFailure { + Logger.error("Failed to update onchain activity metadata for $txId", it, context = TAG) } } } else { @@ -1531,11 +1527,11 @@ class MigrationService @Inject constructor( applyBoostedParents(parents, txId) } } - .onFailure { e -> + .onFailure { Logger.error( - "Failed to create onchain activity for unsupported address $txId: $e", - e, - context = TAG + "Failed to create onchain activity for unsupported address $txId", + it, + context = TAG, ) } } @@ -1544,7 +1540,7 @@ class MigrationService @Inject constructor( if (updatedCount > 0 || createdCount > 0) { Logger.info( "Applied metadata to $updatedCount onchain activities, created $createdCount for unsupported addresses", - context = TAG + context = TAG, ) } } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 96188dbf9..914d1f0c6 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 @@ -167,6 +166,7 @@ import to.bitkit.ui.settings.support.ReportIssueScreen import to.bitkit.ui.settings.support.SupportScreen import to.bitkit.ui.settings.transactionSpeed.CustomFeeSettingsScreen import to.bitkit.ui.settings.transactionSpeed.TransactionSpeedSettingsScreen +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.sheets.BackgroundPaymentsIntroSheet import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.BackupSheet @@ -209,6 +209,7 @@ fun ContentView( transferViewModel: TransferViewModel, settingsViewModel: SettingsViewModel, backupsViewModel: BackupsViewModel, + toaster: Toaster, hazeState: HazeState, modifier: Modifier = Modifier, ) { @@ -360,6 +361,7 @@ fun ContentView( LocalTransferViewModel provides transferViewModel, LocalSettingsViewModel provides settingsViewModel, LocalBackupsViewModel provides backupsViewModel, + LocalToaster provides toaster, LocalDrawerState provides drawerState, LocalBalances provides balance, LocalCurrencies provides currencies, @@ -625,14 +627,6 @@ 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, - title = title, - description = description - ) - }, ) } composableWithDefaultTransitions { diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 2843e38c4..b8a2575e8 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 diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index db4b23dee..33ee8b53f 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 @@ -45,6 +45,7 @@ import to.bitkit.ui.onboarding.TermsOfUseScreen import to.bitkit.ui.onboarding.WarningMultipleDevicesScreen import to.bitkit.ui.screens.MigrationLoadingScreen import to.bitkit.ui.screens.SplashScreen +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.sheets.ForgotPinSheet import to.bitkit.ui.sheets.NewTransactionSheet import to.bitkit.ui.theme.AppThemeSurface @@ -60,9 +61,12 @@ import to.bitkit.viewmodels.MainScreenEffect import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.TransferViewModel import to.bitkit.viewmodels.WalletViewModel +import javax.inject.Inject @AndroidEntryPoint class MainActivity : FragmentActivity() { + @Inject lateinit var toaster: Toaster + private val appViewModel by viewModels() private val walletViewModel by viewModels() private val blocktankViewModel by viewModels() @@ -123,11 +127,12 @@ class MainActivity : FragmentActivity() { scope = scope, appViewModel = appViewModel, walletViewModel = walletViewModel, + toaster = toaster, ) } else { val isAuthenticated by appViewModel.isAuthenticated.collectAsStateWithLifecycle() - IsOnlineTracker(appViewModel) + IsOnlineTracker(appViewModel, toaster) ContentView( appViewModel = appViewModel, walletViewModel = walletViewModel, @@ -137,6 +142,7 @@ class MainActivity : FragmentActivity() { transferViewModel = transferViewModel, settingsViewModel = settingsViewModel, backupsViewModel = backupsViewModel, + toaster = toaster, hazeState = hazeState, modifier = Modifier.hazeSource(hazeState, zIndex = 0f), ) @@ -173,7 +179,7 @@ class MainActivity : FragmentActivity() { } val currentToast by appViewModel.currentToast.collectAsStateWithLifecycle() - ToastOverlay( + ToastHost( toast = currentToast, hazeState = hazeState, onDismiss = { appViewModel.hideToast() }, @@ -229,6 +235,7 @@ private fun OnboardingNav( scope: CoroutineScope, appViewModel: AppViewModel, walletViewModel: WalletViewModel, + toaster: Toaster, ) { NavHost( navController = startupNavController, @@ -265,7 +272,7 @@ private fun OnboardingNav( walletViewModel.setInitNodeLifecycleState() walletViewModel.createWallet(bip39Passphrase = null) }.onFailure { - appViewModel.toast(it) + toaster.error(it) } } }, @@ -295,7 +302,7 @@ private fun OnboardingNav( appViewModel.resetIsAuthenticatedState() walletViewModel.restoreWallet(mnemonic, passphrase) }.onFailure { - appViewModel.toast(it) + toaster.error(it) } } } @@ -310,7 +317,7 @@ private fun OnboardingNav( appViewModel.resetIsAuthenticatedState() walletViewModel.createWallet(bip39Passphrase = passphrase) }.onFailure { - appViewModel.toast(it) + 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..046eaf14a 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 @@ -63,6 +63,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.copyToClipboard @@ -73,11 +74,10 @@ import kotlin.time.ExperimentalTime @Composable fun NodeInfoScreen( navController: NavController, + toaster: Toaster = LocalToaster.current, ) { val wallet = walletViewModel ?: return - val app = appViewModel ?: return val settings = settingsViewModel ?: return - val context = LocalContext.current val isRefreshing by wallet.isRefreshing.collectAsStateWithLifecycle() val isDevModeEnabled by settings.isDevModeEnabled.collectAsStateWithLifecycle() @@ -91,10 +91,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..c84420288 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,19 @@ 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.models.ToastText import to.bitkit.repositories.ConnectivityState +import to.bitkit.ui.LocalToaster +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.viewmodels.AppViewModel @Composable fun IsOnlineTracker( app: AppViewModel, + toaster: Toaster = LocalToaster.current, ) { - val context = LocalContext.current val connectivityState by app.isOnline.collectAsStateWithLifecycle(initialValue = ConnectivityState.CONNECTED) val (isFirstEmission, setIsFirstEmission) = remember { mutableStateOf(true) } @@ -30,18 +31,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 = ToastText(R.string.other__connection_back_title), + body = ToastText(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 = ToastText(R.string.other__connection_issue), + body = ToastText(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..d8f9a2ccb 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,8 @@ 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.models.ToastText +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.utils.Logger import javax.inject.Inject @@ -21,6 +21,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 +43,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 = ToastText(R.string.security__mnemonic_load_error)) return@launch } @@ -66,7 +63,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..57d03994d 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,11 @@ 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.models.ToastText 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 +32,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 +74,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 = ToastText(R.string.common__error), + body = ToastText(R.string.other__logs_export_error), ) } ) @@ -98,10 +98,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 = ToastText(R.string.common__error), + body = ToastText(R.string.settings__support__link_error), ) } } @@ -119,12 +118,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 = ToastText(R.string.security__wiped_title), + body = ToastText(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..bb729822a 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,8 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.TextInput @@ -74,10 +74,10 @@ 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.utils.Logger -import to.bitkit.viewmodels.AppViewModel import java.util.concurrent.Executors const val SCAN_REQUEST_KEY = "SCAN_REQUEST" @@ -91,10 +91,9 @@ fun QrScanningScreen( navController: NavController, inSheet: Boolean = false, onBack: () -> Unit = { navController.popBackStack() }, + toaster: Toaster = LocalToaster.current, onScanSuccess: (String) -> Unit, ) { - val app = appViewModel ?: return - val (scanResult, setScanResult) = remember { mutableStateOf(null) } // Handle scan result @@ -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 = ToastText(R.string.other__qr_error_header), + body = ToastText(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 = ToastText(R.string.wallet__send_clipboard_empty_title), + body = ToastText(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..841311104 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,10 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster 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 @@ -25,6 +25,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settingsViewModel +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.shared.util.shareZipFile import to.bitkit.viewmodels.DevSettingsViewModel @@ -32,8 +33,8 @@ import to.bitkit.viewmodels.DevSettingsViewModel fun DevSettingsScreen( navController: NavController, viewModel: DevSettingsViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { - val app = appViewModel ?: return val activity = activityListViewModel ?: return val settings = settingsViewModel ?: return val context = LocalContext.current @@ -78,63 +79,63 @@ fun DevSettingsScreen( title = "Reset Settings State", onClick = { settings.reset() - app.toast(type = Toast.ToastType.SUCCESS, title = "Settings state reset") + toaster.success(title = ToastText("Settings state reset")) } ) SettingsTextButtonRow( title = "Reset All Activities", onClick = { activity.removeAllActivities() - app.toast(type = Toast.ToastType.SUCCESS, title = "Activities removed") + toaster.success(title = ToastText("Activities removed")) } ) SettingsTextButtonRow( title = "Reset Backup State", onClick = { viewModel.resetBackupState() - app.toast(type = Toast.ToastType.SUCCESS, title = "Backup state reset") + toaster.success(title = ToastText("Backup state reset")) } ) SettingsTextButtonRow( title = "Reset Widgets State", onClick = { viewModel.resetWidgetsState() - app.toast(type = Toast.ToastType.SUCCESS, title = "Widgets state reset") + toaster.success(title = ToastText("Widgets state reset")) } ) SettingsTextButtonRow( title = "Refresh Currency Rates", onClick = { viewModel.refreshCurrencyRates() - app.toast(type = Toast.ToastType.SUCCESS, title = "Currency rates refreshed") + toaster.success(title = ToastText("Currency rates refreshed")) } ) SettingsTextButtonRow( title = "Reset App Database", onClick = { viewModel.resetDatabase() - app.toast(type = Toast.ToastType.SUCCESS, title = "Database state reset") + toaster.success(title = ToastText("Database state reset")) } ) SettingsTextButtonRow( title = "Reset Blocktank State", onClick = { viewModel.resetBlocktankState() - app.toast(type = Toast.ToastType.SUCCESS, title = "Blocktank state reset") + toaster.success(title = ToastText("Blocktank state reset")) } ) SettingsTextButtonRow( title = "Reset Cache Store", onClick = { viewModel.resetCacheStore() - app.toast(type = Toast.ToastType.SUCCESS, title = "Cache store reset") + toaster.success(title = ToastText("Cache store reset")) } ) SettingsTextButtonRow( title = "Wipe App", onClick = { viewModel.wipeWallet() - app.toast(type = Toast.ToastType.SUCCESS, title = "Wallet wiped") + toaster.success(title = ToastText("Wallet wiped")) } ) @@ -145,14 +146,14 @@ fun DevSettingsScreen( onClick = { val count = 100 activity.generateRandomTestData(count) - app.toast(type = Toast.ToastType.SUCCESS, title = "Generated $count test activities") + toaster.success(title = ToastText("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 = ToastText("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..4504ac690 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,8 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton @@ -35,6 +35,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.transfer.components.TransferAnimationView +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.removeAccentTags @@ -51,8 +52,8 @@ fun SavingsProgressScreen( wallet: WalletViewModel, onContinueClick: () -> Unit = {}, onTransferUnavailable: () -> Unit = {}, + toaster: Toaster = LocalToaster.current, ) { - val context = LocalContext.current var progressState by remember { mutableStateOf(SavingsProgressState.PROGRESS) } // Effect to close channels & update UI @@ -70,10 +71,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 = ToastText(R.string.lightning__close_error), + body = ToastText(R.string.lightning__close_error_msg), ) onTransferUnavailable() } else { @@ -81,10 +81,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 = ToastText(R.string.lightning__close_error), + body = ToastText(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..a4b14560d 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 @@ -11,10 +11,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -26,10 +23,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 @@ -63,11 +58,9 @@ fun SpendingAdvancedScreen( amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val currentOnOrderCreated by rememberUpdatedState(onOrderCreated) - val app = appViewModel ?: return val state by viewModel.spendingUiState.collectAsStateWithLifecycle() val order = state.order ?: return val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - var isLoading by remember { mutableStateOf(false) } val transferValues by viewModel.transferValues.collectAsStateWithLifecycle() @@ -83,19 +76,6 @@ fun SpendingAdvancedScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> currentOnOrderCreated() - is TransferEffect.ToastException -> { - isLoading = false - app.toast(effect.e) - } - - is TransferEffect.ToastError -> { - isLoading = false - app.toast( - type = Toast.ToastType.ERROR, - title = effect.title, - description = effect.description, - ) - } } } } @@ -109,14 +89,11 @@ fun SpendingAdvancedScreen( uiState = state, transferValues = transferValues, isValid = isValid, - isLoading = isLoading, + isLoading = state.isLoading, amountInputViewModel = amountInputViewModel, currencies = currencies, onBack = onBackClick, - onContinue = { - isLoading = true - viewModel.onSpendingAdvancedContinue(amountUiState.sats) - }, + onContinue = { viewModel.onSpendingAdvancedContinue(amountUiState.sats) }, ) } 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..7f4545eca 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 @@ -12,7 +12,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue 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 @@ -21,8 +20,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.ToastText import to.bitkit.repositories.CurrencyState import to.bitkit.ui.LocalCurrencies +import to.bitkit.ui.LocalToaster import to.bitkit.ui.components.Display import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -38,6 +39,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.withAccent @@ -54,15 +56,13 @@ fun SpendingAmountScreen( viewModel: TransferViewModel, onBackClick: () -> Unit = {}, onOrderCreated: () -> Unit = {}, - toastException: (Throwable) -> Unit, - toast: (title: String, description: String) -> Unit, + toaster: Toaster = LocalToaster.current, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), ) { val uiState by viewModel.spendingUiState.collectAsStateWithLifecycle() val isNodeRunning by viewModel.isNodeRunning.collectAsStateWithLifecycle() val amountUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current LaunchedEffect(Unit) { viewModel.updateLimits() @@ -72,8 +72,6 @@ fun SpendingAmountScreen( viewModel.transferEffects.collect { effect -> when (effect) { TransferEffect.OnOrderCreated -> onOrderCreated() - is TransferEffect.ToastError -> toast(effect.title, effect.description) - is TransferEffect.ToastException -> toastException(effect.e) } } } @@ -88,10 +86,12 @@ fun SpendingAmountScreen( val quarter = uiState.balanceAfterFeeQuarter() val max = uiState.maxAllowedToSend if (quarter > max) { - toast( - context.getString(R.string.lightning__spending_amount__error_max__title), - context.getString(R.string.lightning__spending_amount__error_max__description) - .replace("{amount}", "$max"), + toaster.error( + title = ToastText(R.string.lightning__spending_amount__error_max__title), + body = ToastText( + R.string.lightning__spending_amount__error_max__description, + mapOf("amount" to "$max"), + ), ) } val cappedQuarter = min(quarter, max) 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..7cf3b39fd 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 @@ -1,10 +1,8 @@ package to.bitkit.ui.screens.transfer.external -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow @@ -20,7 +18,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,18 +26,18 @@ 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 @Suppress("LongParameterList") @HiltViewModel class ExternalNodeViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val walletRepo: WalletRepo, 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 +71,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 = ToastText(R.string.lightning__error_add_title), + body = ToastText(R.string.lightning__error_add), ) } } @@ -89,10 +86,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 = ToastText(R.string.lightning__error_add_uri)) } } } @@ -101,14 +95,13 @@ class ExternalNodeViewModel @Inject constructor( val maxAmount = _uiState.value.amount.max 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( + R.string.lightning__spending_amount__error_max__description, + mapOf("amount" to maxAmount.formatToModernDisplay()), + ), + ) return } @@ -134,6 +127,18 @@ class ExternalNodeViewModel @Inject constructor( updateNetworkFee() } + suspend fun validateCustomFeeRate(): Boolean { + val feeRate = _uiState.value.customFeeRate ?: 0u + if (feeRate == 0u) { + toaster.info( + title = ToastText(R.string.wallet__min_possible_fee_rate), + body = ToastText(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 +185,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( + R.string.lightning__error_channel_setup_msg, + mapOf("raw" to 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..16f543eb3 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 = ToastText(R.string.other__lnurl_channel_success_title), + body = ToastText(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..511c2bbbe 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,9 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster 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 @@ -163,7 +163,7 @@ fun ActivityDetailScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val app = appViewModel ?: return@Box + val toaster = LocalToaster.current val copyToastTitle = stringResource(R.string.common__copied) val tags by detailViewModel.tags.collectAsStateWithLifecycle() @@ -194,7 +194,6 @@ fun ActivityDetailScreen( } } - val context = LocalContext.current val blocktankInfo by blocktankViewModel?.info?.collectAsStateWithLifecycle() ?: remember { mutableStateOf(null) } @@ -227,10 +226,9 @@ fun ActivityDetailScreen( isCpfpChild = isCpfpChild, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> - app.toast( - type = Toast.ToastType.SUCCESS, - title = copyToastTitle, - description = text.ellipsisMiddle(40) + toaster.success( + title = ToastText(copyToastTitle), + body = ToastText(text.ellipsisMiddle(40)) ) }, feeRates = feeRates, @@ -250,36 +248,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 = ToastText(R.string.wallet__boost_success_title), + body = ToastText(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 = ToastText(R.string.wallet__boost_error_title), + body = ToastText(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 = ToastText(R.string.wallet__send_fee_error), + body = ToastText(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 = ToastText(R.string.wallet__send_fee_error), + body = ToastText(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..dc3b87d19 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,9 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster 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 @@ -131,7 +131,7 @@ fun ActivityExploreScreen( is ActivityDetailViewModel.ActivityLoadState.Success -> { val item = loadState.activity - val app = appViewModel ?: return@ScreenColumn + val toaster = LocalToaster.current val context = LocalContext.current val txDetails by detailViewModel.txDetails.collectAsStateWithLifecycle() @@ -166,10 +166,9 @@ fun ActivityExploreScreen( txDetails = txDetails, boostTxDoesExist = boostTxDoesExist, onCopy = { text -> - app.toast( - type = Toast.ToastType.SUCCESS, - title = toastMessage, - description = text.ellipsisMiddle(40), + toaster.success( + title = ToastText(toastMessage), + body = ToastText(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..d4af62400 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,7 @@ 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.LocalToaster import to.bitkit.ui.blocktankViewModel import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.Caption13Up @@ -46,6 +46,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -61,8 +62,8 @@ fun ReceiveAmountScreen( onBack: () -> Unit, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { - val app = appViewModel ?: return val wallet = walletViewModel ?: return val blocktank = blocktankViewModel ?: return val lightningState by wallet.lightningState.collectAsStateWithLifecycle() @@ -106,7 +107,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..ba5cbe9c6 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,12 @@ 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.ToastText 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.LocalToaster import to.bitkit.ui.components.BottomSheetPreview import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.FillWidth @@ -54,6 +53,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -75,9 +75,8 @@ fun SendAmountScreen( onEvent: (SendEvent) -> Unit, currencies: CurrencyState = LocalCurrencies.current, amountInputViewModel: AmountInputViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { - val app = appViewModel - val context = LocalContext.current val amountInputUiState: AmountInputUiState by amountInputViewModel.uiState.collectAsStateWithLifecycle() val currentOnEvent by rememberUpdatedState(onEvent) @@ -108,10 +107,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 = ToastText(R.string.wallet__send_max_spending__title), + body = ToastText(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..62efaadb1 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,36 @@ 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.models.ToastText 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 +73,12 @@ 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( + title = ToastText( + R.string.wallet__error_utxo_load, + mapOf("raw" to 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..193b3202c 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,13 @@ 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.ToastText 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 +32,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 +106,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 = ToastText(R.string.wallet__min_possible_fee_rate), + body = ToastText(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 = ToastText(R.string.wallet__max_possible_fee_rate), + body = ToastText(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..0e5403243 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,8 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BottomSheetPreview @@ -71,6 +71,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.scanner.QrCodeAnalyzer import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -90,9 +91,8 @@ private const val TAG = "SendRecipientScreen" fun SendRecipientScreen( onEvent: (SendEvent) -> Unit, modifier: Modifier = Modifier, + toaster: Toaster = LocalToaster.current, ) { - val app = appViewModel - // Context & lifecycle val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -139,10 +139,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 = ToastText(R.string.other__qr_error_header), + body = ToastText(R.string.other__qr_error_text), ) } } @@ -173,11 +172,12 @@ fun SendRecipientScreen( isCameraInitialized = true }.onFailure { Logger.error("Camera initialization failed", it, context = TAG) - app?.toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.other__qr_error_header), - description = context.getString(R.string.other__camera_init_error) - .replace("{message}", it.message.orEmpty()) + toaster.error( + title = ToastText(R.string.other__qr_error_header), + body = ToastText( + R.string.other__camera_init_error, + mapOf("message" to it.message.orEmpty()) + ), ) isCameraInitialized = false } @@ -206,7 +206,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..cd971d717 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,8 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption import to.bitkit.ui.components.Caption13Up @@ -38,6 +38,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.Colors import to.bitkit.ui.walletViewModel import to.bitkit.utils.Logger @@ -47,10 +48,10 @@ import to.bitkit.utils.Logger fun BlocktankRegtestScreen( navController: NavController, viewModel: BlocktankRegtestViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { val coroutineScope = rememberCoroutineScope() val wallet = walletViewModel ?: return - val app = appViewModel ?: return val walletState by wallet.walletState.collectAsStateWithLifecycle() ScreenColumn { @@ -112,17 +113,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, - title = "Success", - description = "Deposit successful. TxID: $txId", + toaster.success( + title = ToastText("Success"), + body = ToastText("Deposit successful. TxID: $txId"), ) }.onFailure { Logger.error("Deposit failed", it) - app.toast( - type = Toast.ToastType.ERROR, - title = "Failed to deposit", - description = it.message.orEmpty(), + toaster.error( + title = ToastText("Failed to deposit"), + body = ToastText(it.message.orEmpty()), ) } @@ -158,17 +157,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, - title = "Success", - description = "Successfully mined $count blocks", + toaster.success( + title = ToastText("Success"), + body = ToastText("Successfully mined $count blocks"), ) }.onFailure { Logger.error("Mining failed", it) - app.toast( - type = Toast.ToastType.ERROR, - title = "Failed to mine", - description = it.message.orEmpty(), + toaster.error( + title = ToastText("Failed to mine"), + body = ToastText(it.message.orEmpty()), ) } isMining = false @@ -210,17 +207,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, - title = "Success", - description = "Payment successful. ID: $paymentId", + toaster.success( + title = ToastText("Success"), + body = ToastText("Payment successful. ID: $paymentId"), ) }.onFailure { Logger.error("Payment failed", it) - app.toast( - type = Toast.ToastType.ERROR, - title = "Failed to pay invoice from LND", - description = it.message.orEmpty(), + toaster.error( + title = ToastText("Failed to pay invoice from LND"), + body = ToastText(it.message.orEmpty()), ) } } @@ -277,14 +272,13 @@ fun BlocktankRegtestScreen( forceCloseAfterS = closeAfter, ) Logger.debug("Channel closed successfully with txId: $closingTxId") - app.toast( - type = Toast.ToastType.SUCCESS, - title = "Success", - description = "Channel closed. Closing TxID: $closingTxId" + toaster.success( + title = ToastText("Success"), + body = ToastText("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..89c974daa 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,9 @@ 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.models.ToastText +import to.bitkit.ui.LocalToaster 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 @@ -41,6 +40,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settingsViewModel import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface private const val DEV_MODE_TAP_THRESHOLD = 5 @@ -48,13 +48,12 @@ private const val DEV_MODE_TAP_THRESHOLD = 5 @Composable fun SettingsScreen( navController: NavController, + toaster: Toaster = LocalToaster.current, ) { - val app = appViewModel ?: return 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, @@ -68,29 +67,25 @@ fun SettingsScreen( onBackClick = { navController.popBackStack() }, onCogTap = { haptic.performHapticFeedback(HapticFeedbackType.Confirm) - enableDevModeTapCount = enableDevModeTapCount + 1 + enableDevModeTapCount += 1 if (enableDevModeTapCount >= DEV_MODE_TAP_THRESHOLD) { val newValue = !isDevModeEnabled 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 + toaster.success( + title = ToastText( + when (newValue) { + true -> 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 + body = ToastText( + when (newValue) { + true -> R.string.settings__dev_enabled_message + else -> R.string.settings__dev_disabled_message } - ), + ) ) 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..4057c44bc 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,9 @@ 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.LocalToaster import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.ButtonSize import to.bitkit.ui.components.Caption @@ -46,6 +46,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -56,8 +57,8 @@ import to.bitkit.ui.utils.getBlockExplorerUrl fun AddressViewerScreen( navController: NavController, viewModel: AddressViewerViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { - val app = appViewModel ?: return val context = LocalContext.current val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -77,10 +78,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..64ab29f04 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 @@ -15,7 +15,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 @@ -31,8 +30,8 @@ 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.LocalToaster import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight @@ -47,6 +46,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScanNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.scanner.SCAN_RESULT_KEY +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -55,10 +55,9 @@ fun ElectrumConfigScreen( savedStateHandle: SavedStateHandle, navController: NavController, viewModel: ElectrumConfigViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val app = appViewModel ?: return - val context = LocalContext.current // Handle result from Scanner LaunchedEffect(savedStateHandle) { @@ -74,19 +73,18 @@ 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( + R.string.settings__es__server_updated_message, + mapOf("host" to uiState.host, "port" to 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 = ToastText(R.string.settings__es__server_error), + body = ToastText(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..9f995de6e 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,8 @@ 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.LocalToaster import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.FillHeight @@ -40,6 +39,7 @@ import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.ScanNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.scanner.SCAN_RESULT_KEY +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -48,10 +48,9 @@ fun RgsServerScreen( savedStateHandle: SavedStateHandle, navController: NavController, viewModel: RgsServerViewModel = hiltViewModel(), + toaster: Toaster = LocalToaster.current, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - val app = appViewModel ?: return - val context = LocalContext.current // Handle result from Scanner LaunchedEffect(savedStateHandle) { @@ -67,17 +66,17 @@ 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 = ToastText(R.string.settings__rgs__update_success_title), + body = ToastText(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 = result.exceptionOrNull()?.message + ?.let { ToastText(it) } + ?: ToastText(R.string.common__error_body), testTag = "RgsErrorToast", ) } @@ -136,7 +135,9 @@ private fun Content( value = uiState.rgsUrl, onValueChange = onChangeUrl, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - modifier = Modifier.fillMaxWidth().testTag("RGSUrl") + modifier = Modifier + .fillMaxWidth() + .testTag("RGSUrl") ) FillHeight() 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..0fe0280c3 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,11 @@ 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.models.ToastText 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 +35,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 +87,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 = ToastText(R.string.security__mnemonic_error), + body = ToastText(R.string.security__mnemonic_error_description), ) } } @@ -154,6 +154,13 @@ class BackupNavSheetViewModel @Inject constructor( fun resetState() { _uiState.update { UiState() } } + + fun onMnemonicCopied() { + toaster.success( + title = ToastText(R.string.common__copied), + body = ToastText(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..10c38b07c 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,9 @@ 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.LocalToaster 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 @@ -72,6 +72,7 @@ import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.settings.lightning.components.ChannelStatusView import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.toast.Toaster import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.utils.getBlockExplorerUrl @@ -85,9 +86,9 @@ import java.util.Locale fun ChannelDetailScreen( navController: NavController, viewModel: LightningConnectionsViewModel, + toaster: Toaster = LocalToaster.current, ) { val context = LocalContext.current - val app = appViewModel ?: return val wallet = walletViewModel ?: return val selectedChannel by viewModel.selectedChannel.collectAsStateWithLifecycle() @@ -128,10 +129,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..4301c3b9f 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,13 @@ 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.models.ToastText 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 +51,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 +339,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 = ToastText(R.string.lightning__error_logs), + body = ToastText(R.string.lightning__error_logs_description), ) } } @@ -454,10 +454,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 = ToastText(R.string.lightning__close_success_title), + body = ToastText(R.string.lightning__close_success_msg), ) _closeConnectionUiState.update { @@ -470,10 +469,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 = ToastText(R.string.lightning__close_error), + body = ToastText(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..54f55ba56 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 @@ -9,14 +9,15 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch 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 +27,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(private val scope: CoroutineScope) { // Public state exposed to UI private val _currentToast = MutableStateFlow(null) val currentToast: StateFlow = _currentToast.asStateFlow() @@ -49,13 +50,13 @@ class ToastQueueManager(private val scope: CoroutineScope) { } newQueue } - dismissCurrentToast() + dismiss() } /** * Dismiss current toast and advance to next in queue. */ - fun dismissCurrentToast() { + fun dismiss() { cancelTimer() _currentToast.value = null isPaused = false @@ -66,7 +67,7 @@ class ToastQueueManager(private val scope: CoroutineScope) { /** * Pause current toast timer (called on drag start). */ - fun pauseCurrentToast() { + fun pause() { if (_currentToast.value?.autoHide == true) { isPaused = true cancelTimer() @@ -76,12 +77,12 @@ class ToastQueueManager(private val scope: CoroutineScope) { /** * Resume current toast timer with FULL duration (called on drag end). */ - fun resumeCurrentToast() { + fun resume() { val toast = _currentToast.value if (isPaused && toast != null) { isPaused = false if (toast.autoHide) { - startTimer(toast.visibilityTime) + startTimer(toast.duration) } } } @@ -108,11 +109,11 @@ 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 { delay(duration) 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..1790cb98f --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/shared/toast/Toaster.kt @@ -0,0 +1,42 @@ +package to.bitkit.ui.shared.toast + +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 + +@Singleton +class Toaster @Inject constructor() { + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + val events: SharedFlow = _events.asSharedFlow() + + private fun emit(toast: Toast) = _events.tryEmit(toast) + + fun success(title: ToastText, body: ToastText? = null, testTag: String? = null) = + emit(Toast(ToastType.SUCCESS, title, body, testTag = testTag)) + + fun info(title: ToastText, body: ToastText? = null, testTag: String? = null) = + emit(Toast(ToastType.INFO, title, body, testTag = testTag)) + + fun lightning(title: ToastText, body: ToastText? = null, testTag: String? = null) = + emit(Toast(ToastType.LIGHTNING, title, body, testTag = testTag)) + + fun warn(title: ToastText, body: ToastText? = null, testTag: String? = null) = + emit(Toast(ToastType.WARNING, title, body, testTag = testTag)) + + fun error(title: ToastText, body: ToastText? = null, testTag: String? = null) = + emit(Toast(ToastType.ERROR, title, body, testTag = testTag)) + + fun error(throwable: Throwable) = emit( + Toast( + type = ToastType.ERROR, + title = ToastText(R.string.common__error), + body = throwable.message?.let { ToastText(it) } ?: ToastText(R.string.common__error_body), + ) + ) +} 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..36b792828 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -82,6 +82,7 @@ import to.bitkit.models.NewTransactionSheetDirection import to.bitkit.models.NewTransactionSheetType import to.bitkit.models.Suggestion import to.bitkit.models.Toast +import to.bitkit.models.ToastText import to.bitkit.models.TransactionSpeed import to.bitkit.models.safe import to.bitkit.models.toActivityFilter @@ -102,10 +103,11 @@ 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.AppError import to.bitkit.utils.Logger import to.bitkit.utils.jsonLogOf import to.bitkit.utils.timedsheets.TimedSheetManager @@ -125,8 +127,9 @@ import kotlin.time.ExperimentalTime class AppViewModel @Inject constructor( connectivityRepo: ConnectivityRepo, healthRepo: HealthRepo, - toastManagerProvider: @JvmSuppressWildcards (CoroutineScope) -> ToastQueueManager, timedSheetManagerProvider: @JvmSuppressWildcards (CoroutineScope) -> TimedSheetManager, + toastQueueProvider: @JvmSuppressWildcards (CoroutineScope) -> ToastQueue, + private val toaster: Toaster, @ApplicationContext private val context: Context, @BgDispatcher private val bgDispatcher: CoroutineDispatcher, private val keychain: Keychain, @@ -230,9 +233,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 +449,9 @@ class AppViewModel @Inject constructor( migrationService.setShowingMigrationLoading(false) delay(MIGRATION_AUTH_RESET_DELAY_MS) resetIsAuthenticatedStateInternal() - toast( - type = Toast.ToastType.ERROR, - title = "Migration Warning", - description = "Migration completed but node restart failed. Please restart the app." + toaster.error( + title = ToastText(R.string.wallet__migration_restart_failed_title), + body = ToastText(R.string.wallet__migration_restart_failed_body), ) } @@ -532,20 +532,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 = ToastText(R.string.lightning__channel_opened_title), + body = ToastText(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 = ToastText(R.string.wallet__toast_transaction_removed_title), + body = ToastText(R.string.wallet__toast_transaction_removed_description), testTag = "TransactionRemovedToast", ) } @@ -557,25 +555,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 = ToastText(R.string.wallet__toast_transaction_unconfirmed_title), + body = ToastText(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 = ToastText( + when (isReceive) { + true -> R.string.wallet__toast_received_transaction_replaced_title + else -> R.string.wallet__toast_transaction_replaced_title + } + ), + body = ToastText( + 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 +583,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 = ToastText(R.string.wallet__toast_payment_failed_title), + body = ToastText(R.string.wallet__toast_payment_failed_description), testTag = "PaymentFailedToast", ) @@ -774,11 +773,12 @@ class AppViewModel @Inject constructor( if (lnurl is LnurlParams.LnurlPay) { val minSendable = lnurl.data.minSendableSat() if (_sendUiState.value.amount < minSendable) { - toast( - type = Toast.ToastType.ERROR, - title = context.getString(R.string.wallet__lnurl_pay__error_min__title), - description = context.getString(R.string.wallet__lnurl_pay__error_min__description) - .replace("{amount}", minSendable.toString()), + toaster.error( + title = ToastText(R.string.wallet__lnurl_pay__error_min__title), + body = ToastText( + R.string.wallet__lnurl_pay__error_min__description, + mapOf("amount" to minSendable.toString()) + ), testTag = "LnurlPayAmountTooLowToast", ) return @@ -829,10 +829,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 = ToastText(R.string.wallet__send_clipboard_empty_title), + body = ToastText(R.string.wallet__send_clipboard_empty_text), ) return } @@ -874,10 +873,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 = ToastText(R.string.other__scan_err_decoding), + body = ToastText(R.string.other__scan_err_interpret_title), ) } } @@ -891,10 +889,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 = ToastText(R.string.other__scan_err_decoding), + body = ToastText(R.string.other__scan__error__expired), ) Logger.debug( @@ -960,10 +957,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 = ToastText(R.string.other__scan_err_decoding), + body = ToastText(R.string.other__scan__error__expired), ) return } @@ -972,10 +968,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 = ToastText(R.string.wallet__error_insufficient_funds_title), + body = ToastText(R.string.wallet__error_insufficient_funds_msg), ) return } @@ -1016,10 +1011,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 = ToastText(R.string.other__lnurl_pay_error), + body = ToastText(R.string.other__lnurl_pay_error_no_capacity), ) return } @@ -1064,10 +1058,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 = ToastText(R.string.other__lnurl_withdr_error), + body = ToastText(R.string.other__lnurl_withdr_error_minmax), ) return } @@ -1113,21 +1106,23 @@ class AppViewModel @Inject constructor( k1 = k1, domain = domain, ).onFailure { - toast( - type = Toast.ToastType.WARNING, - title = context.getString(R.string.other__lnurl_auth_error), - description = context.getString(R.string.other__lnurl_auth_error_msg) - .replace("{raw}", it.message?.takeIf { m -> m.isNotBlank() } ?: it.javaClass.simpleName), + toaster.warn( + title = ToastText(R.string.other__lnurl_auth_error), + body = ToastText( + R.string.other__lnurl_auth_error_msg, + mapOf("raw" to (it.message?.takeIf { m -> m.isNotBlank() } ?: it.javaClass.simpleName)) + ), ) }.onSuccess { - toast( - type = Toast.ToastType.SUCCESS, - title = context.getString(R.string.other__lnurl_auth_success_title), - description = when (domain.isNotBlank()) { - true -> context.getString(R.string.other__lnurl_auth_success_msg_domain) - .replace("{domain}", domain) - - else -> context.getString(R.string.other__lnurl_auth_success_msg_no_domain) + toaster.success( + title = ToastText(R.string.other__lnurl_auth_success_title), + body = when (domain.isNotBlank()) { + true -> ToastText( + R.string.other__lnurl_auth_success_msg_domain, + mapOf("domain" to domain) + ) + + else -> ToastText(R.string.other__lnurl_auth_success_msg_no_domain) }, ) } @@ -1153,9 +1148,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 +1317,7 @@ class AppViewModel @Inject constructor( it.copy(decodedInvoice = invoice) } }.onFailure { - toast(Exception(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) + toaster.error(AppError(context.getString(R.string.wallet__error_lnurl_invoice_fetch))) hideSheet() return } @@ -1335,7 +1330,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(AppError(context.getString(R.string.wallet__error_invalid_bitcoin_address))) hideSheet() return } @@ -1357,10 +1352,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, - title = context.getString(R.string.wallet__error_sending_title), - description = e.message ?: context.getString(R.string.common__error_body) + toaster.error( + title = ToastText(R.string.wallet__error_sending_title), + body = ToastText(e.message ?: context.getString(R.string.common__error_body)), ) hideSheet() } @@ -1408,7 +1402,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 +1443,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 = ToastText(R.string.other__lnurl_withdr_success_title), + body = ToastText(R.string.other__lnurl_withdr_success_msg), ) hideSheet() _sendUiState.update { it.copy(isLoading = false) } @@ -1482,7 +1475,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 +1499,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 +1782,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(viewModelScope) + val currentToast: StateFlow = toastQueue.currentToast - fun hideToast() = toastManager.dismissCurrentToast() + fun hideToast() = toastQueue.dismiss() - fun pauseToast() = toastManager.pauseCurrentToast() + fun pauseToast() = toastQueue.pause() - fun resumeToast() = toastManager.resumeCurrentToast() + fun resumeToast() = toastQueue.resume() // endregion // region security @@ -1866,10 +1821,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 = ToastText(R.string.security__wiped_title), + body = ToastText(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..21c1a0f6e 100644 --- a/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/DevSettingsViewModel.kt @@ -1,13 +1,11 @@ package to.bitkit.viewmodels -import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.messaging.FirebaseMessaging import com.synonym.bitkitcore.testNotification import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.launch import kotlinx.coroutines.tasks.await import to.bitkit.R @@ -18,20 +16,19 @@ 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.models.ToastText 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 @Suppress("TooManyFunctions", "LongParameterList") @HiltViewModel class DevSettingsViewModel @Inject constructor( - @ApplicationContext private val context: Context, private val firebaseMessaging: FirebaseMessaging, private val lightningRepo: LightningRepo, private val walletRepo: WalletRepo, @@ -41,29 +38,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(title = ToastText("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(title = ToastText("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(title = ToastText("Registered for notifications")) } + .onFailure { toaster.error(it) } } fun testLspNotification() = viewModelScope.launch { @@ -74,9 +68,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(title = ToastText("LSP notification sent to this device")) }.onFailure { - ToastEventBus.send(type = Toast.ToastType.WARNING, title = "Error testing LSP notification") + toaster.warn(title = ToastText("Error testing LSP notification")) } } @@ -90,47 +84,32 @@ class DevSettingsViewModel @Inject constructor( ) } - fun resetWidgetsState() = viewModelScope.launch { - widgetsStore.reset() - } + fun resetWidgetsState() = viewModelScope.launch { widgetsStore.reset() } - fun refreshCurrencyRates() = viewModelScope.launch { - currencyRepo.triggerRefresh() - } + fun refreshCurrencyRates() = viewModelScope.launch { currencyRepo.triggerRefresh() } fun zipLogsForSharing(onReady: (Uri) -> Unit) { viewModelScope.launch { 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( + title = ToastText(R.string.lightning__error_logs), + body = ToastText(R.string.lightning__error_logs_description), ) } } } - fun resetBackupState() = viewModelScope.launch { - cacheStore.update { it.copy(backupStatuses = mapOf()) } - } + fun resetBackupState() = viewModelScope.launch { cacheStore.update { it.copy(backupStatuses = mapOf()) } } - fun wipeWallet() = viewModelScope.launch { - walletRepo.wipeWallet() - } + fun wipeWallet() = viewModelScope.launch { walletRepo.wipeWallet() } - fun resetCacheStore() = viewModelScope.launch { - cacheStore.reset() - } + fun resetCacheStore() = viewModelScope.launch { cacheStore.reset() } - fun resetDatabase() = viewModelScope.launch { - appDb.clearAllTables() - } + fun resetDatabase() = viewModelScope.launch { appDb.clearAllTables() } - fun resetBlocktankState() = viewModelScope.launch { - blocktankRepo.resetState() - } + fun resetBlocktankState() = viewModelScope.launch { blocktankRepo.resetState() } fun wipeLogs() = Logger.reset() } diff --git a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt index d8aced251..3971f17ab 100644 --- a/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/LdkDebugViewModel.kt @@ -17,10 +17,10 @@ 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.models.ToastText 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 +31,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 +44,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", - ) - } + toaster.warn(title = ToastText("Please enter a node URI")) return } connectPeer(uri) @@ -60,12 +56,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", - ) - } + toaster.warn(title = ToastText("Clipboard is empty")) return } @@ -81,26 +72,15 @@ class LdkDebugViewModel @Inject constructor( lightningRepo.connectPeer(peer) }.onSuccess { result -> result.onSuccess { - ToastEventBus.send( - type = Toast.ToastType.INFO, - title = "Peer connected", - ) + toaster.info(title = ToastText("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(title = ToastText("Failed to connect peer"), body = e.message?.let { ToastText(it) }) } }.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(title = ToastText("Invalid node URI format"), body = e.message?.let { ToastText(it) }) } _uiState.update { it.copy(isLoading = false) } } @@ -118,15 +98,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(title = ToastText("Network graph info logged")) } else { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Failed to get network graph info", - ) + toaster.warn(title = ToastText("Failed to get network graph info")) } } } @@ -137,17 +111,13 @@ 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(title = ToastText("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( + title = ToastText("Failed to export network graph"), + body = e.message?.let { ToastText(it) } ) } _uiState.update { it.copy(isLoading = false) } @@ -160,17 +130,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(title = ToastText("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(title = ToastText("Failed to list VSS keys"), body = e.message?.let { ToastText(it) }) } _uiState.update { it.copy(isLoading = false) } } @@ -182,17 +145,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(title = ToastText("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(title = ToastText("Failed to delete VSS keys"), body = e.message?.let { ToastText(it) }) } _uiState.update { it.copy(isLoading = false) } } @@ -208,24 +164,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(title = ToastText("Deleted key: $key")) } else { - ToastEventBus.send( - type = Toast.ToastType.WARNING, - title = "Key not found: $key", - ) + toaster.warn(title = ToastText("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(title = ToastText("Failed to delete key"), body = e.message?.let { ToastText(it) }) } _uiState.update { it.copy(isLoading = false) } } @@ -237,18 +183,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(title = ToastText("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(title = ToastText("Failed to restart node"), body = e.message?.let { ToastText(it) }) } _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..601fedad4 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() @@ -90,13 +91,9 @@ class TransferViewModel @Inject constructor( fun onConfirmAmount(satsAmount: Long) { val values = blocktankRepo.calculateLiquidityOptions(satsAmount.toULong()).getOrNull() if (values == null || values.maxLspBalanceSat == 0uL) { - setTransferEffect( - TransferEffect.ToastError( - title = context.getString(R.string.lightning__spending_amount__error_max__title), - description = context.getString( - R.string.lightning__spending_amount__error_max__description_zero - ), - ) + toaster.error( + title = ToastText(R.string.lightning__spending_amount__error_max__title), + body = ToastText(R.string.lightning__spending_amount__error_max__description_zero), ) return } @@ -120,7 +117,7 @@ class TransferViewModel @Inject constructor( delay(1.seconds) // Give time to settle the UI _spendingUiState.update { it.copy(isLoading = false) } }.onFailure { e -> - setTransferEffect(TransferEffect.ToastException(e)) + toaster.error(e) delay(1.seconds) // Give time to settle the UI _spendingUiState.update { it.copy(isLoading = false) } } @@ -169,6 +166,7 @@ class TransferViewModel @Inject constructor( fun onSpendingAdvancedContinue(receivingAmountSats: Long) { viewModelScope.launch { + _spendingUiState.update { it.copy(isLoading = true) } runCatching { val oldOrder = _spendingUiState.value.order ?: return@launch val newOrder = blocktankRepo.createOrder( @@ -180,11 +178,13 @@ class TransferViewModel @Inject constructor( order = newOrder, defaultOrder = oldOrder, isAdvanced = true, + isLoading = false, ) } setTransferEffect(TransferEffect.OnOrderCreated) }.onFailure { e -> - setTransferEffect(TransferEffect.ToastException(e)) + _spendingUiState.update { it.copy(isLoading = false) } + toaster.error(e) } } } @@ -213,7 +213,7 @@ class TransferViewModel @Inject constructor( launch { watchOrder(order.id) } } .onFailure { error -> - ToastEventBus.send(error) + toaster.error(error) } } } @@ -321,7 +321,7 @@ class TransferViewModel @Inject constructor( }.onFailure { exception -> _spendingUiState.update { it.copy(isLoading = false) } Logger.error("Failure", exception) - setTransferEffect(TransferEffect.ToastException(exception)) + toaster.error(exception) } } } @@ -458,10 +458,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 = ToastText(R.string.lightning__force_failed_title), + body = ToastText(R.string.lightning__force_failed_msg), ) return@runCatching } @@ -482,26 +481,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 = ToastText(R.string.lightning__force_failed_title), + body = ToastText(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 = ToastText(R.string.lightning__force_failed_title), + body = ToastText(R.string.lightning__force_failed_msg), ) } _isForceTransferLoading.value = false @@ -569,7 +565,5 @@ 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 } // 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..f0e23fbe1 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 = ToastText(R.string.wallet__migration_error_title), + body = ToastText(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,29 +301,20 @@ 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 = ToastText(R.string.common__success), + body = ToastText(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(it) } } } 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) - ) + walletRepo.updateBip21Invoice(amountSats).onFailure { + toaster.error(it) } } @@ -335,7 +326,7 @@ class WalletViewModel @Inject constructor( fun wipeWallet() = viewModelScope.launch(bgDispatcher) { walletRepo.wipeWallet().onFailure { - ToastEventBus.send(it) + toaster.error(it) } } @@ -346,7 +337,7 @@ class WalletViewModel @Inject constructor( backupRepo.scheduleFullBackup() } .onFailure { - ToastEventBus.send(it) + toaster.error(it) } } @@ -358,7 +349,7 @@ class WalletViewModel @Inject constructor( mnemonic = mnemonic, bip39Passphrase = bip39Passphrase, ).onFailure { - ToastEventBus.send(it) + toaster.error(it) } } @@ -366,13 +357,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..01ab36cdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,6 +84,8 @@ Usable Yes Yes, Proceed + An error has occurred. Please try again later. + Rates currently unavailable Depends on the fee Depends on the fee Depends on the fee @@ -1094,6 +1096,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 +1115,10 @@ Withdraw Bitcoin Fee Exceeds Maximum Limit Lower the custom fee and try again. + Please restore your wallet manually using your recovery phrase + Migration Failed + Migration completed but node restart failed. Please restart the app. + Migration Warning 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..ee87ce5c5 --- /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.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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 + + private fun testQueue(block: suspend TestScope.() -> Unit): Unit = test { + sut = ToastQueue(this) + block() + } + + @Test + fun `enqueue shows toast immediately when queue empty`() = testQueue { + val toast = createToast() + + sut.enqueue(toast) + + assertEquals(toast, sut.currentToast.value) + } + + @Test + fun `enqueue queues toast when another is displayed`() = testQueue { + 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`() = testQueue { + 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.dismiss() + + assertNull(sut.currentToast.value) + } + + @Test + fun `auto-hide timer dismisses toast after duration`() = testQueue { + 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`() = testQueue { + val toast = createToast(autoHide = true) + + sut.enqueue(toast) + advanceTimeBy(1000) + sut.pause() + advanceTimeBy(5000) + + assertEquals(toast, sut.currentToast.value) + } + + @Test + fun `resume restarts auto-hide timer`() = testQueue { + val toast = createToast(autoHide = true) + + sut.enqueue(toast) + advanceTimeBy(1000) + sut.pause() + advanceTimeBy(5000) + sut.resume() + advanceTimeBy(2000) + + assertEquals(toast, sut.currentToast.value) + + advanceTimeBy(1001) + + assertNull(sut.currentToast.value) + } + + @Test + fun `max queue size drops oldest when exceeded`() = testQueue { + 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`() = testQueue { + 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`() = testQueue { + val toast = createToast(autoHide = false) + + sut.enqueue(toast) + advanceTimeBy(10_000) + + assertEquals(toast, sut.currentToast.value) + + sut.dismiss() + + 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, )