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.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import com.bitcointxoko.gudariwallet.util.satsToFiat
import com.bitcointxoko.gudariwallet.util.parseApiError import com.bitcointxoko.gudariwallet.util.parseApiError
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch 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 com.bitcointxoko.gudariwallet.util.lnurlProtocolError
import androidx.fragment.app.FragmentActivity 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.ui.balance.BalanceViewModel import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.util.BiometricHelper import com.bitcointxoko.gudariwallet.util.BiometricHelper
private const val TAG = "WalletViewModel" private const val TAG = "WalletViewModel"
@@ -40,60 +36,22 @@ class WalletViewModel(
val paymentCache: PaymentCacheRepository, val paymentCache: PaymentCacheRepository,
private val balancePrefs: BalancePrefsStore, private val balancePrefs: BalancePrefsStore,
val balanceVm : BalanceViewModel, val balanceVm : BalanceViewModel,
val fiatVm : FiatViewModel
) : ViewModel() { ) : ViewModel() {
// ── Balance state ───────────────────────────────────────────────────────── // ── Balance state ─────────────────────────────────────────────────────────
val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState val balanceState: StateFlow<BalanceState> get() = balanceVm.balanceState
// ── Balance visible state ───────────────────────────────────────────────── val balanceHidden: StateFlow<Boolean> get() = fiatVm.balanceHidden
private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) val selectedCurrency: StateFlow<String?> get() = fiatVm.selectedCurrency
val balanceHidden: StateFlow<Boolean> = _balanceHidden val fiatSatsPerUnit: StateFlow<Double?> get() = fiatVm.fiatSatsPerUnit
fun toggleBalanceVisibility() { fun toggleBalanceVisibility() = fiatVm.toggleBalanceVisibility()
val next = !_balanceHidden.value fun setSelectedCurrency(c: String?) = fiatVm.setSelectedCurrency(c)
_balanceHidden.value = next suspend fun getSupportedCurrencies() = fiatVm.getSupportedCurrencies()
balancePrefs.setBalanceHidden(next) val fiatRate: Double? get() = fiatVm.fiatRate
} val fiatCurrency: String? get() = fiatVm.fiatCurrency
// ── 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
}
// ── Other states ────────────────────────────────────────────────────────── // ── Other states ──────────────────────────────────────────────────────────
private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle) private val _receiveState = MutableStateFlow<ReceiveState>(ReceiveState.Idle)
val receiveState: StateFlow<ReceiveState> = _receiveState val receiveState: StateFlow<ReceiveState> = _receiveState
private val _sendState = MutableStateFlow<SendState>(SendState.Idle) private val _sendState = MutableStateFlow<SendState>(SendState.Idle)
@@ -110,48 +68,7 @@ class WalletViewModel(
fetchLightningAddress() 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 ──────────────────────────────────────────── // ── WebSocket event collection ────────────────────────────────────────────
private fun observePaymentEvents() { private fun observePaymentEvents() {
viewModelScope.launch { viewModelScope.launch {
balanceVm.incomingPayment.collect { event -> balanceVm.incomingPayment.collect { event ->
@@ -12,6 +12,7 @@ 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 import com.bitcointxoko.gudariwallet.ui.balance.BalanceViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
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 {
@@ -23,6 +24,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
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 balanceVm = BalanceViewModel(repo, balancePrefs)
val fiatVm = FiatViewModel(repo, balancePrefs, balanceVm)
balanceVm.onBalanceRefreshed = { fiatVm.onBalanceRefreshed() }
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -32,7 +35,8 @@ class WalletViewModelFactory(private val app: Application) : ViewModelProvider.F
lnurlCache, lnurlCache,
paymentCache, paymentCache,
balancePrefs, balancePrefs,
balanceVm balanceVm,
fiatVm
) as T ) as T
} }
} }
@@ -27,12 +27,13 @@ class BalanceViewModel(
// Emits after every successful balance refresh so other ViewModels // Emits after every successful balance refresh so other ViewModels
// (e.g. FiatViewModel) can react without polling. // (e.g. FiatViewModel) can react without polling.
private val _balanceRefreshed = MutableSharedFlow<Long>(extraBufferCapacity = 1) private val _balanceRefreshed = MutableSharedFlow<Long>(extraBufferCapacity = 1)
val balanceRefreshed: SharedFlow<Long> = _balanceRefreshed
// Emits incoming payment events so ReceiveViewModel can update its state. // Emits incoming payment events so ReceiveViewModel can update its state.
private val _incomingPayment = MutableSharedFlow<IncomingPaymentEvent>(extraBufferCapacity = 1) private val _incomingPayment = MutableSharedFlow<IncomingPaymentEvent>(extraBufferCapacity = 1)
val incomingPayment: SharedFlow<IncomingPaymentEvent> = _incomingPayment val incomingPayment: SharedFlow<IncomingPaymentEvent> = _incomingPayment
var onBalanceRefreshed: (() -> Unit)? = null
init { init {
loadBalanceWithCache() loadBalanceWithCache()
observePaymentEvents() observePaymentEvents()
@@ -85,7 +86,8 @@ class BalanceViewModel(
lastUpdated = System.currentTimeMillis() lastUpdated = System.currentTimeMillis()
) )
persistBalance(sats) persistBalance(sats)
_balanceRefreshed.tryEmit(sats) // notify FiatViewModel _balanceRefreshed.tryEmit(sats)
onBalanceRefreshed?.invoke()
} }
.onFailure { e -> .onFailure { e ->
Log.w(TAG, "BALANCE [NET ERROR ] ${e.message}") 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) }
}
}