From bba41033e5a465c372808e778609c8c4b41f8a7a Mon Sep 17 00:00:00 2001 From: rasputin Date: Mon, 1 Jun 2026 20:57:14 +0200 Subject: [PATCH] refactor: WalletViewModel extract FiatViewModel --- .../gudariwallet/ui/WalletViewModel.kt | 103 ++------------- .../gudariwallet/ui/WalletViewModelFactory.kt | 6 +- .../ui/balance/BalanceViewModel.kt | 6 +- .../gudariwallet/ui/fiat/FiatViewModel.kt | 120 ++++++++++++++++++ 4 files changed, 139 insertions(+), 96 deletions(-) create mode 100644 app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt index ff89baf..10c424b 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -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 get() = balanceVm.balanceState - // ── Balance visible state ───────────────────────────────────────────────── - private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) - val balanceHidden: StateFlow = _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 = 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 = _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 get() = fiatVm.balanceHidden + val selectedCurrency: StateFlow get() = fiatVm.selectedCurrency + val fiatSatsPerUnit: StateFlow 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.Idle) val receiveState: StateFlow = _receiveState private val _sendState = MutableStateFlow(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 { - fiatCache.getCurrencies()?.let { return it } - // Cache miss (memory + disk) — fetch from network - return fetchAndCacheCurrencies() - } - - private suspend fun fetchAndCacheCurrencies(): List { - 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 -> diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index df59f3c..5c47556 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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 create(modelClass: Class): 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 } } \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt index eeef129..c2fde44 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt @@ -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(extraBufferCapacity = 1) - val balanceRefreshed: SharedFlow = _balanceRefreshed // Emits incoming payment events so ReceiveViewModel can update its state. private val _incomingPayment = MutableSharedFlow(extraBufferCapacity = 1) val incomingPayment: SharedFlow = _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}") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt new file mode 100644 index 0000000..e2e2f65 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/fiat/FiatViewModel.kt @@ -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 = _balanceHidden + + fun toggleBalanceVisibility() { + val next = !_balanceHidden.value + _balanceHidden.value = next + balancePrefs.setBalanceHidden(next) + } + + // ── Selected currency ──────────────────────────────────────────────────── + private val _selectedCurrency = MutableStateFlow(balancePrefs.selectedCurrency) + val selectedCurrency: StateFlow = _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 = 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 { + fiatCache.getCurrencies()?.let { return it } + return fetchAndCacheCurrencies() + } + + private suspend fun fetchAndCacheCurrencies(): List { + return runCatching { repo.getSupportedCurrencies() } + .getOrElse { e -> + Log.w(TAG, "FIAT [CURRENCIES] network fetch failed — ${e.message}") + emptyList() + } + .also { list -> fiatCache.putCurrencies(list) } + } +}