refactor: WalletViewModel extract FiatViewModel

This commit is contained in:
2026-06-01 20:57:14 +02:00
parent 933681c4b2
commit bba41033e5
4 changed files with 139 additions and 96 deletions
@@ -13,22 +13,18 @@ import com.bitcointxoko.gudariwallet.util.RouteHintAnalyzer
import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.satsToFiat
import com.bitcointxoko.gudariwallet.util.parseApiError
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.SharingStarted
import com.bitcointxoko.gudariwallet.util.lnurlProtocolError
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.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel"
@@ -40,60 +36,22 @@ class WalletViewModel(
val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore,
val balanceVm : BalanceViewModel,
val fiatVm : FiatViewModel
) : ViewModel() {
// ── Balance state ─────────────────────────────────────────────────────────
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
// ── Balance visible state ─────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden
fun toggleBalanceVisibility() {
val next = !_balanceHidden.value
_balanceHidden.value = next
balancePrefs.setBalanceHidden(next)
}
// ── Fiat cache ────────────────────────────────────────────────────────────
private val fiatCache = FiatRateCache(balancePrefs)
val fiatRate: Double?
get() = fiatCache.currentRate
val fiatCurrency: String? get() = _selectedCurrency.value
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
.mapNotNull { state ->
(state as? BalanceState.Success)?.let { s ->
if (s.fiatAmount != null && s.sats > 0)
s.sats.toDouble() / s.fiatAmount
else null
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)
private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency)
val selectedCurrency: StateFlow<String?> = _selectedCurrency
fun setSelectedCurrency(currency: String?) {
if (currency == _selectedCurrency.value) return
_selectedCurrency.value = currency
balancePrefs.setSelectedCurrency(currency)
fiatCache.invalidateRate()
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() }
} else {
balanceVm.applyFiat(null, null) // ← fix
}
}
companion object {
private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes
}
val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden
val selectedCurrency: StateFlow<String?> get() = fiatVm.selectedCurrency
val fiatSatsPerUnit: StateFlow<Double?> get() = fiatVm.fiatSatsPerUnit
fun toggleBalanceVisibility() = fiatVm.toggleBalanceVisibility()
fun setSelectedCurrency(c: String?) = fiatVm.setSelectedCurrency(c)
suspend fun getSupportedCurrencies() = fiatVm.getSupportedCurrencies()
val fiatRate: Double? get() = fiatVm.fiatRate
val fiatCurrency: String? get() = fiatVm.fiatCurrency
// ── Other states ──────────────────────────────────────────────────────────
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState
private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
@@ -110,48 +68,7 @@ class WalletViewModel(
fetchLightningAddress()
}
// ── Fiat rate fetch + apply ───────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return
val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
?: run {
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
}
.also { fresh -> fiatCache.putRate(currency, fresh) }
}
// ← 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 }
// Cache miss (memory + disk) — fetch from network
return fetchAndCacheCurrencies()
}
private suspend fun fetchAndCacheCurrencies(): List<String> {
return runCatching { repo.getSupportedCurrencies() }
.getOrElse { e ->
Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}")
emptyList()
}
.also { list -> fiatCache.putCurrencies(list) }
}
// ── WebSocket event collection ────────────────────────────────────────────
private fun observePaymentEvents() {
viewModelScope.launch {
balanceVm.incomingPayment.collect { event ->
@@ -12,6 +12,7 @@ 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
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@@ -23,6 +24,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
val repo = LNbitsWalletRepository(api = api, secrets = secrets, context = app)
val balancePrefs = BalancePrefsStore(app)
val balanceVm = BalanceViewModel(repo, balancePrefs)
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST")
@@ -32,7 +35,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
lnurlCache,
paymentCache,
balancePrefs,
balanceVm
balanceVm,
fiatVm
) as T
}
}
@@ -27,12 +27,13 @@ class BalanceViewModel(
// 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
var onBalanceRefreshed: (() -> Unit)? = null
init {
loadBalanceWithCache()
observePaymentEvents()
@@ -85,7 +86,8 @@ class BalanceViewModel(
lastUpdated = System.currentTimeMillis()
)
persistBalance(sats)
_balanceRefreshed.tryEmit(sats) // notify FiatViewModel
_balanceRefreshed.tryEmit(sats)
onBalanceRefreshed?.invoke()
}
.onFailure { e ->
Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}")
@@ -0,0 +1,120 @@
package com.bitcointxoko.gudariwallet.ui.fiat
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.bitcointxoko.gudariwallet.data.BalancePrefsStore
import com.bitcointxoko.gudariwallet.data.FiatRateCache
import com.bitcointxoko.gudariwallet.data.WalletRepository
import com.bitcointxoko.gudariwallet.ui.BalanceState
import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.util.satsToFiat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
private const val TAG = "FiatViewModel"
private const val RATE_TTL_MS = 5 * 60 * 1_000L // 5 minutes
class FiatViewModel(
private val repo : WalletRepository,
private val balancePrefs: BalancePrefsStore,
private val balanceVm : BalanceViewModel,
) : ViewModel() {
private val fiatCache = FiatRateCache(balancePrefs)
val fiatRate: Double?
get() = fiatCache.currentRate
val fiatCurrency: String?
get() = _selectedCurrency.value
// ── Balance hidden ───────────────────────────────────────────────────────
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden)
val balanceHidden: StateFlow<Boolean> = _balanceHidden
fun toggleBalanceVisibility() {
val next = !_balanceHidden.value
_balanceHidden.value = next
balancePrefs.setBalanceHidden(next)
}
// ── Selected currency ────────────────────────────────────────────────────
private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency)
val selectedCurrency: StateFlow<String?> = _selectedCurrency
fun setSelectedCurrency(currency: String?) {
if (currency == _selectedCurrency.value) return
_selectedCurrency.value = currency
balancePrefs.setSelectedCurrency(currency)
fiatCache.invalidateRate()
Log.d(TAG, "FIAT [CURRENCY ] changed to ${currency ?: "none"} — cache invalidated")
if (currency != null) {
viewModelScope.launch { fetchAndApplyFiatRate() }
} else {
balanceVm.applyFiat(null, null)
}
}
// ── Sats-per-fiat-unit derived flow ──────────────────────────────────────
// Used by UnitWheelPicker to convert between sats and fiat amounts.
val fiatSatsPerUnit: StateFlow<Double?> = balanceVm.balanceState
.mapNotNull { state ->
(state as? BalanceState.Success)?.let { s ->
if (s.fiatAmount != null && s.sats > 0)
s.sats.toDouble() / s.fiatAmount
else null
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = null
)
// ── Called by BalanceViewModel after every successful balance refresh ────
// Keeps fiat display in sync whenever the balance changes.
fun onBalanceRefreshed() {
viewModelScope.launch { fetchAndApplyFiatRate() }
}
// ── Rate fetch + apply ───────────────────────────────────────────────────
private suspend fun fetchAndApplyFiatRate() {
val currency = _selectedCurrency.value ?: return
val rate: Double = fiatCache.getRate(currency, RATE_TTL_MS)
?: run {
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
}
.also { fresh -> fiatCache.putRate(currency, fresh) }
}
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 }
return fetchAndCacheCurrencies()
}
private suspend fun fetchAndCacheCurrencies(): List<String> {
return runCatching { repo.getSupportedCurrencies() }
.getOrElse { e ->
Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}")
emptyList()
}
.also { list -> fiatCache.putCurrencies(list) }
}
}