Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
678375b
refactor: replace ToastEventBus with injectable Toaster
ovitrif Jan 16, 2026
9551767
refactor: rename toast description param to body
ovitrif Jan 16, 2026
4de2023
refactor: use Duration for toast visibility
ovitrif Jan 16, 2026
2e4539f
refactor: remove Context from Toaster
ovitrif Jan 16, 2026
2e3d209
refactor: rename ToastQueueManager to ToastQueue
ovitrif Jan 16, 2026
40fd9e8
refactor: make ToastQueue inherit BaseCoroutineScope
ovitrif Jan 16, 2026
7f66cc1
docs: add *Manager anti-pattern rule
ovitrif Jan 16, 2026
24954cb
refactor: expose Toaster via CompositionLocal
ovitrif Jan 16, 2026
ea268f1
refactor: use LocalToaster in composables
ovitrif Jan 16, 2026
3300eab
refactor: rename ToastView to ToastContent
ovitrif Jan 16, 2026
ba8f01b
test: add ToastQueue behavior tests
ovitrif Jan 16, 2026
1cf4ba1
refactor: add @StringRes overloads to Toaster
ovitrif Jan 16, 2026
c203aad
refactor: use @StringRes in composable toasts
ovitrif Jan 16, 2026
2222c84
refactor: use @StringRes in ViewModel toasts
ovitrif Jan 16, 2026
bec28f5
fix: localize hardcoded toast strings
ovitrif Jan 16, 2026
41d6895
refactor: prettify Toast and Toaster APIs
ovitrif Jan 17, 2026
8a0224f
refactor: use ToastText() factory constructor
ovitrif Jan 17, 2026
3a48521
refactor: make Toaster methods non-suspend
ovitrif Jan 17, 2026
c3abb6a
refactor: migrate external app.toast calls to toaster
ovitrif Jan 17, 2026
8fbefa6
refactor: migrate internal AppViewModel toast calls
ovitrif Jan 17, 2026
9b9bec2
refactor: remove toast() methods from AppViewModel
ovitrif Jan 17, 2026
7711538
refactor: add ToastText.Parameterized
ovitrif Jan 17, 2026
583f2f4
refactor: consolidate Toaster to single ToastText API
ovitrif Jan 17, 2026
a261fe4
refactor: restore ToastQueue to use CoroutineScope
ovitrif Jan 17, 2026
645b86c
refactor: use composable parameter pattern for toaster
ovitrif Jan 17, 2026
399f2d0
refactor: remove toastException lambdas
ovitrif Jan 17, 2026
3dd269c
refactor: make toaster private, inject in MainActivity
ovitrif Jan 17, 2026
885acf9
feat: localize migration restart toast
ovitrif Jan 17, 2026
ccb5951
refactor: restore ToastQueue provider for testability
ovitrif Jan 17, 2026
ddb9c86
feat: localize currency rates error toast
ovitrif Jan 17, 2026
1ad6d20
refactor: use ToastText.Parameterized in ExternalNodeViewModel
ovitrif Jan 17, 2026
7442487
refactor: clean up toast API usage
ovitrif Jan 17, 2026
77ae810
chore: cleanup
ovitrif Jan 17, 2026
1566d2b
fix: pass toaster to IsOnlineTracker
ovitrif Jan 17, 2026
81f0b31
fix: localize error
ovitrif Jan 17, 2026
800bdc6
fix: convert remaining toast calls to toaster API
ovitrif Jan 19, 2026
0bb7a7b
Merge branch 'master' into refactor/toast
ovitrif Jan 19, 2026
36b6799
Merge branch 'master' into refactor/toast
ovitrif Jan 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,13 @@ suspend fun getData(): Result<Data> = 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
- ALWAYS wrap suspend functions in `withContext(bgDispatcher)` if in domain layer, using ctor injected prop `@BgDispatcher private val bgDispatcher: CoroutineDispatcher`
- NEVER use `*Manager` suffix for classes, PREFER narrow-scope constructs that do not tend to grow into unmaintainable god objects

### Architecture Guidelines

Expand Down
10 changes: 3 additions & 7 deletions app/src/main/java/to/bitkit/di/ViewModelModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,16 @@ 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
@InstallIn(SingletonComponent::class)
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
}
19 changes: 12 additions & 7 deletions app/src/main/java/to/bitkit/models/Toast.kt
Original file line number Diff line number Diff line change
@@ -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 }
47 changes: 47 additions & 0 deletions app/src/main/java/to/bitkit/models/ToastText.kt
Original file line number Diff line number Diff line change
@@ -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<String, String>,
) : 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<String, String>,
): 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
}
16 changes: 8 additions & 8 deletions app/src/main/java/to/bitkit/repositories/BackupRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,18 @@ 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
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
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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())
),
)
}
Expand Down
23 changes: 12 additions & 11 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -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),
)
}
}
Expand All @@ -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 }
Expand Down
14 changes: 4 additions & 10 deletions app/src/main/java/to/bitkit/services/CoreService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -647,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

Expand All @@ -673,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,
Expand All @@ -693,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))
}
Expand Down
24 changes: 10 additions & 14 deletions app/src/main/java/to/bitkit/services/MigrationService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
)
}
}
Expand All @@ -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,
)
}
}
Expand Down
12 changes: 3 additions & 9 deletions app/src/main/java/to/bitkit/ui/ContentView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -209,6 +209,7 @@ fun ContentView(
transferViewModel: TransferViewModel,
settingsViewModel: SettingsViewModel,
backupsViewModel: BackupsViewModel,
toaster: Toaster,
hazeState: HazeState,
modifier: Modifier = Modifier,
) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Routes.SpendingConfirm> {
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/to/bitkit/ui/Locals.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +30,7 @@ val LocalActivityListViewModel = staticCompositionLocalOf<ActivityListViewModel?
val LocalTransferViewModel = staticCompositionLocalOf<TransferViewModel?> { null }
val LocalSettingsViewModel = staticCompositionLocalOf<SettingsViewModel?> { null }
val LocalBackupsViewModel = staticCompositionLocalOf<BackupsViewModel?> { null }
val LocalToaster = staticCompositionLocalOf<Toaster> { error("Toaster not provided") }

val appViewModel: AppViewModel?
@Composable get() = LocalAppViewModel.current
Expand Down
Loading
Loading