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.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.util.LnurlMetadataParser
import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer
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.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel"
@@ -39,12 +39,11 @@ class WalletViewModel(
val lnurlCache: LnurlCacheRepository,
val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore,
val balanceVm : BalanceViewModel,
) : ViewModel() {
// ── Balance state ─────────────────────────────────────────────────────────
private val _balanceState = MutableStateFlow<BalanceState>(BalanceState.Loading)
val balanceState: StateFlow<BalanceState> = _balanceState
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
// ── Balance visible state ─────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden
@@ -59,7 +58,7 @@ class WalletViewModel(
val fiatRate: Double?
get() = fiatCache.currentRate
val fiatCurrency: String? get() = _selectedCurrency.value
val fiatSatsPerUnit: StateFlow<Double?> = balanceState
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
.mapNotNull { state ->
(state as? BalanceState.Success)?.let { s ->
if (s.fiatAmount != null && s.sats > 0)
@@ -79,13 +78,12 @@ class WalletViewModel(
if (currency == _selectedCurrency.value) return
_selectedCurrency.value = 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")
if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() }
} else {
val current = _balanceState.value as? BalanceState.Success ?: return
_balanceState.value = current.copy(fiatAmount = null, fiatCurrency = null)
balanceVm.applyFiat(null, null) // ← fix
}
}
@@ -108,106 +106,34 @@ class WalletViewModel(
// ── Init ──────────────────────────────────────────────────────────────────
init {
loadBalanceWithCache()
observePaymentEvents()
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 ───────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return
val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
?: run {
// Cache miss — fetch from network
runCatching { repo.getFiatRate(currency) }
.getOrElse { e ->
Log.w(TAG, "FIAT [RATE ERROR ] $currency${e.message}")
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) }
}
val current = _balanceState.value as? BalanceState.Success ?: return
val fiat = satsToFiat(current.sats, rate)
Log.d(TAG, "FIAT [APPLY ] ${current.sats} sats ÷ $rate sats/$currency = $fiat $currency")
_balanceState.value = current.copy(fiatAmount = fiat, fiatCurrency = currency)
// ← fix: delegate to balanceVm instead of writing to _balanceState
val currentSats = (balanceVm.balanceState.value as? BalanceState.Success)?.sats ?: return
val fiat = satsToFiat(currentSats, rate)
Log.d(TAG, "FIAT [APPLY ] $currentSats sats ÷ $rate sats/$currency = $fiat $currency")
balanceVm.applyFiat(fiat, currency)
}
// ── Currency list ────────────────────────────────────────────────────────
suspend fun getSupportedCurrencies(): List<String> {
fiatCache.getCurrencies()?.let { return it }
@@ -228,24 +154,7 @@ class WalletViewModel(
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()
fetchAndApplyFiatRate()
if (!event.isOutgoing) {
balanceVm.incomingPayment.collect { event ->
val rs = _receiveState.value
if (rs is ReceiveState.InvoiceReady) {
Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received")
@@ -258,9 +167,6 @@ class WalletViewModel(
}
}
}
}
private fun fetchLightningAddress() {
// If we already have a persisted address, show it immediately and
// still refresh in the background (address could have changed).
@@ -672,7 +578,10 @@ class WalletViewModel(
_clipboardOfferState.value = ClipboardOfferState.None
}
fun refreshBalance() = balanceVm.refreshBalance()
// ── Private helpers ───────────────────────────────────────────────────────
private fun buildLnurlReadyState(raw: LnurlScanResponse, lnurl: String): SendState.LnurlReady {
return SendState.LnurlReady(
callback = raw.callback ?: "",
@@ -11,6 +11,7 @@ import com.bitcointxoko.gudariwallet.data.LnurlCacheRepository
import com.bitcointxoko.gudariwallet.data.PaymentCacheRepository
import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.NodeAliasService
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
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 repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs)
val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST")
return WalletViewModel(
balancePrefs = balancePrefs,
repo = repo,
aliasRepo = aliasRepo,
lnurlCache = lnurlCache,
paymentCache = paymentCache
repo,
aliasRepo,
lnurlCache,
paymentCache,
balancePrefs,
balanceVm
) 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?
)