refactor: WalletViewModel extract BalanceViewModel
This commit is contained in:
@@ -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?
|
||||
)
|
||||
Reference in New Issue
Block a user