refactor: WalletViewModel extract BalanceViewModel

This commit is contained in:
2026-06-01 20:41:32 +02:00
parent 234fa11af8
commit 933681c4b2
3 changed files with 186 additions and 121 deletions
@@ -8,7 +8,6 @@ import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.NodeAliasRepository import com.bitcointxoko.gudariwallet.data.NodeAliasRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
@@ -29,6 +28,7 @@ import androidx.fragment.app.FragmentActivity
import com.bitcointxoko.gudariwallet.api.RouteHintHop import com.bitcointxoko.gudariwallet.api.RouteHintHop
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.util.BiometricHelper import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
@@ -39,12 +39,11 @@ class WalletViewModel(
val lnurlCache: LnurlCacheRepository, val lnurlCache: LnurlCacheRepository,
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
val balanceVm : BalanceViewModel,
) : ViewModel() { ) : ViewModel() {
// ── Balance state ───────────────────────────────────────────────────────── // ── Balance state ─────────────────────────────────────────────────────────
private val _balanceState = MutableStateFlow<BalanceState>(BalanceState.Loading) val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
val balanceState: StateFlow<BalanceState> = _balanceState
// ── Balance visible state ───────────────────────────────────────────────── // ── Balance visible state ─────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden val balanceHidden: StateFlow<Boolean> = _balanceHidden
@@ -59,7 +58,7 @@ class WalletViewModel(
val fiatRate: Double? val fiatRate: Double?
get() = fiatCache.currentRate get() = fiatCache.currentRate
val fiatCurrency: String? get() = _selectedCurrency.value val fiatCurrency: String? get() = _selectedCurrency.value
val fiatSatsPerUnit: StateFlow<Double?> = balanceState val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
.mapNotNull { state -> .mapNotNull { state ->
(state as? BalanceState.Success)?.let { s -> (state as? BalanceState.Success)?.let { s ->
if (s.fiatAmount != null && s.sats > 0) if (s.fiatAmount != null && s.sats > 0)
@@ -79,13 +78,12 @@ class WalletViewModel(
if (currency == _selectedCurrency.value) return if (currency == _selectedCurrency.value) return
_selectedCurrency.value = currency _selectedCurrency.value = currency
balancePrefs.setSelectedCurrency(currency) balancePrefs.setSelectedCurrency(currency)
fiatCache.invalidateRate() // ← replaces the three raw-var resets fiatCache.invalidateRate()
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated") Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
if (currency != null) { if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() } viewModelScope.launch { fetchAndApplyFiatRate() }
} else { } else {
val current = _balanceState.value as? BalanceState.Success ?: return balanceVm.applyFiat(null, null) // ← fix
_balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null)
} }
} }
@@ -108,106 +106,34 @@ class WalletViewModel(
// ── Init ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
init { init {
loadBalanceWithCache()
observePaymentEvents() observePaymentEvents()
fetchLightningAddress() fetchLightningAddress()
} }
// ── Balance loading ───────────────────────────────────────────────────────
private fun loadBalanceWithCache() {
val savedSats = balancePrefs.savedBalanceSats
val savedAt = balancePrefs.balanceSavedAt
val ageMs = System.currentTimeMillis() - savedAt
if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) {
Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — showing immediately")
_balanceState.value = BalanceState.Success(
sats = savedSats,
isRefreshing = true,
lastUpdated = savedAt
)
} else {
if (savedSats >= 0) {
Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL ${WalletConstants.BALANCE_CACHE_TTL_MS / 1000}s — cold load")
} else {
Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load")
}
_balanceState.value = BalanceState.Loading
}
refreshBalance()
}
fun refreshBalance() {
val current = _balanceState.value
if (current is BalanceState.Success && !current.isRefreshing) {
Log.d(TAG, "BALANCE [REFRESH ] Manual refresh triggered (current: ${current.sats} sats)")
_balanceState.value = current.copy(isRefreshing = true)
}
viewModelScope.launch {
runCatching { repo.getBalance() }
.onSuccess { balance ->
val sats = balance.sats
val prev = (_balanceState.value as? BalanceState.Success)?.sats
when {
prev == null -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — cold load complete")
prev == sats -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — matches cache, no change")
else -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
}
_balanceState.value = BalanceState.Success(
sats = sats,
isRefreshing = false,
lastUpdated = System.currentTimeMillis()
)
persistBalance(sats)
fetchAndApplyFiatRate()
}
.onFailure { e ->
Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}")
val staleSats = (_balanceState.value as? BalanceState.Success)?.sats
?: balancePrefs.savedBalanceSats.takeIf { it >= 0 }
if (staleSats != null) {
Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats")
} else {
Log.w(TAG, "BALANCE [NET ERROR ] No stale value available — showing error state")
}
_balanceState.value = BalanceState.Error(
message = e.message ?: "Failed to load balance",
staleSats = staleSats
)
}
}
}
private fun persistBalance(sats: Long) {
balancePrefs.persistBalance(sats)
Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences")
}
// ── Fiat rate fetch + apply ─────────────────────────────────────────────── // ── Fiat rate fetch + apply ───────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() { private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return val currency = _selectedCurrency.value ?: return
val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS) val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
?: run { ?: run {
// Cache miss — fetch from network
runCatching { repo.getFiatRate(currency) } runCatching { repo.getFiatRate(currency) }
.getOrElse { e -> .getOrElse { e ->
Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}") Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}")
Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display") Log.w(TAG, "FIAT [RATE MISS ] $currency — no fallback available, suppressing fiat display")
return // nothing to show; leave fiatAmount = null return
} }
.also { fresh -> fiatCache.putRate(currency, fresh) } .also { fresh -> fiatCache.putRate(currency, fresh) }
} }
val current = _balanceState.value as? BalanceState.Success ?: return // ← fix: delegate to balanceVm instead of writing to _balanceState
val fiat = satsToFiat(current.sats, rate) val currentSats = (balanceVm.balanceState.value as? BalanceState.Success)?.sats ?: return
Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency") val fiat = satsToFiat(currentSats, rate)
_balanceState.value = current.copy(fiatAmount = fiat, fiatCurrency = currency) Log.d(TAG, "FIAT [APPLY ] $currentSats sats ÷ $rate sats/$currency = $fiat $currency")
balanceVm.applyFiat(fiat, currency)
} }
// ── Currency list ──────────────────────────────────────────────────────── // ── Currency list ────────────────────────────────────────────────────────
suspend fun getSupportedCurrencies(): List<String> { suspend fun getSupportedCurrencies(): List<String> {
fiatCache.getCurrencies()?.let { return it } fiatCache.getCurrencies()?.let { return it }
@@ -228,39 +154,19 @@ class WalletViewModel(
private fun observePaymentEvents() { private fun observePaymentEvents() {
viewModelScope.launch { viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event -> balanceVm.incomingPayment.collect { event ->
Log.d(TAG, "BALANCE [WS EVENT ] ${if (event.isOutgoing) "SENT" else "RECEIVED"} ${event.amountSats} sats") val rs = _receiveState.value
if (rs is ReceiveState.InvoiceReady) {
val current = _balanceState.value Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received")
if (current is BalanceState.Success) { _receiveState.value = ReceiveState.PaymentReceived(
val delta = if (event.isOutgoing) -event.amountSats else event.amountSats amountSats = event.amountSats,
val newSats = (current.sats + delta).coerceAtLeast(0L) memo = event.memo,
Log.d(TAG, "BALANCE [WS DELTA ] ${current.sats} ${if (delta >= 0) "+" else ""}$delta = $newSats sats (optimistic)") checkingId = rs.paymentHash
_balanceState.value = current.copy(sats = newSats, isRefreshing = true) )
persistBalance(newSats)
} else {
Log.w(TAG, "BALANCE [WS EVENT ] Received event but balance state is ${current::class.simpleName} — skipping delta")
}
refreshBalance()
fetchAndApplyFiatRate()
if (!event.isOutgoing) {
val rs = _receiveState.value
if (rs is ReceiveState.InvoiceReady) {
Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received")
_receiveState.value = ReceiveState.PaymentReceived(
amountSats = event.amountSats,
memo = event.memo,
checkingId = rs.paymentHash
)
}
} }
} }
} }
} }
private fun fetchLightningAddress() { private fun fetchLightningAddress() {
// If we already have a persisted address, show it immediately and // If we already have a persisted address, show it immediately and
// still refresh in the background (address could have changed). // still refresh in the background (address could have changed).
@@ -672,7 +578,10 @@ class WalletViewModel(
_clipboardOfferState.value = ClipboardOfferState.None _clipboardOfferState.value = ClipboardOfferState.None
} }
fun refreshBalance() = balanceVm.refreshBalance()
// ── Private helpers ─────────────────────────────────────────────────────── // ── Private helpers ───────────────────────────────────────────────────────
private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady { private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady {
return SendState.LnurlReady( return SendState.LnurlReady(
callback = raw.callback ?: "", callback = raw.callback ?: "",
@@ -11,6 +11,7 @@ import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
@@ -21,15 +22,17 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc) val aliasRepo = LNbitsNodeAliasRepository(nodeAliasSvc)
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app) val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
val balancePrefs = BalancePrefsStore(app) val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
return WalletViewModel( return WalletViewModel(
balancePrefs = balancePrefs, repo,
repo = repo, aliasRepo,
aliasRepo = aliasRepo, lnurlCache,
lnurlCache = lnurlCache, paymentCache,
paymentCache = paymentCache balancePrefs,
balanceVm
) as T ) as T
} }
} }
@@ -0,0 +1,153 @@
package com.bitcointxoko.gudariwallet.ui.balance
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.BalanceState
import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "BalanceViewModel"
class BalanceViewModel(
private val repo : WalletRepository,
private val balancePrefs : BalancePrefsStore,
) : ViewModel() {
private val _balanceState = MutableStateFlow<BalanceState>(BalanceState.Loading)
val balanceState: StateFlow<BalanceState> = _balanceState
// Emits after every successful balance refresh so other ViewModels
// (e.g. FiatViewModel) can react without polling.
private val _balanceRefreshed = MutableSharedFlow<Long>(extraBufferCapacity = 1)
val balanceRefreshed: SharedFlow<Long> = _balanceRefreshed
// Emits incoming payment events so ReceiveViewModel can update its state.
private val _incomingPayment = MutableSharedFlow<IncomingPaymentEvent>(extraBufferCapacity = 1)
val incomingPayment: SharedFlow<IncomingPaymentEvent> = _incomingPayment
init {
loadBalanceWithCache()
observePaymentEvents()
}
private fun loadBalanceWithCache() {
val savedSats = balancePrefs.savedBalanceSats
val savedAt = balancePrefs.balanceSavedAt
val ageMs = System.currentTimeMillis() - savedAt
if (savedSats >= 0 && ageMs < WalletConstants.BALANCE_CACHE_TTL_MS) {
Log.d(TAG, "BALANCE [CACHE HIT ] $savedSats sats (age ${ageMs / 1000}s) — showing immediately")
_balanceState.value = BalanceState.Success(
sats = savedSats,
isRefreshing = true,
lastUpdated = savedAt
)
} else {
if (savedSats >= 0) {
Log.d(TAG, "BALANCE [CACHE STALE] $savedSats sats, age ${ageMs / 1000}s > TTL ${WalletConstants.BALANCE_CACHE_TTL_MS / 1000}s — cold load")
} else {
Log.d(TAG, "BALANCE [CACHE MISS ] No persisted balance — cold load")
}
_balanceState.value = BalanceState.Loading
}
refreshBalance()
}
fun refreshBalance() {
val current = _balanceState.value
if (current is BalanceState.Success && !current.isRefreshing) {
Log.d(TAG, "BALANCE [REFRESH ] Manual refresh triggered (current: ${current.sats} sats)")
_balanceState.value = current.copy(isRefreshing = true)
}
viewModelScope.launch {
runCatching { repo.getBalance() }
.onSuccess { balance ->
val sats = balance.sats
val prev = (_balanceState.value as? BalanceState.Success)?.sats
when {
prev == null -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — cold load complete")
prev == sats -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — matches cache, no change")
else -> Log.d(TAG, "BALANCE [NETWORK ] $sats sats — was $prev sats (diff ${sats - prev})")
}
_balanceState.value = BalanceState.Success(
sats = sats,
isRefreshing = false,
lastUpdated = System.currentTimeMillis()
)
persistBalance(sats)
_balanceRefreshed.tryEmit(sats) // notify FiatViewModel
}
.onFailure { e ->
Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}")
val staleSats = (_balanceState.value as? BalanceState.Success)?.sats
?: balancePrefs.savedBalanceSats.takeIf { it >= 0 }
if (staleSats != null) {
Log.w(TAG, "BALANCE [NET ERROR ] Falling back to stale value: $staleSats sats")
} else {
Log.w(TAG, "BALANCE [NET ERROR ] No stale value available — showing error state")
}
_balanceState.value = BalanceState.Error(
message = e.message ?: "Failed to load balance",
staleSats = staleSats
)
}
}
}
private fun persistBalance(sats: Long) {
balancePrefs.persistBalance(sats)
Log.d(TAG, "BALANCE [PERSIST ] $sats sats written to SharedPreferences")
}
private fun observePaymentEvents() {
viewModelScope.launch {
WalletNotificationService.paymentEvents.collect { event ->
Log.d(TAG, "BALANCE [WS EVENT ] ${if (event.isOutgoing) "SENT" else "RECEIVED"} ${event.amountSats} sats")
val current = _balanceState.value
if (current is BalanceState.Success) {
val delta = if (event.isOutgoing) -event.amountSats else event.amountSats
val newSats = (current.sats + delta).coerceAtLeast(0L)
Log.d(TAG, "BALANCE [WS DELTA ] ${current.sats} ${if (delta >= 0) "+" else ""}$delta = $newSats sats (optimistic)")
_balanceState.value = current.copy(sats = newSats, isRefreshing = true)
persistBalance(newSats)
} else {
Log.w(TAG, "BALANCE [WS EVENT ] Received event but balance state is ${current::class.simpleName} — skipping delta")
}
refreshBalance()
if (!event.isOutgoing) {
_incomingPayment.tryEmit(
IncomingPaymentEvent(
amountSats = event.amountSats,
memo = event.memo
)
)
}
}
}
}
fun applyFiat(fiatAmount: Double?, fiatCurrency: String?) {
val current = _balanceState.value as? BalanceState.Success ?: return
_balanceState.value = current.copy(fiatAmount = fiatAmount, fiatCurrency = fiatCurrency)
}
}
/** Thin event type so ReceiveViewModel doesn't depend on the WS model directly. */
data class IncomingPaymentEvent(
val amountSats: Long,
val memo : String?
)