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 dc09c83..ff89baf 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModel.kt @@ -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.Loading) - val balanceState: StateFlow = _balanceState - + val balanceState: StateFlow get() = balanceVm.balanceState // ── Balance visible state ───────────────────────────────────────────────── private val _balanceHidden = MutableStateFlow(balancePrefs.isBalanceHidden) val balanceHidden: StateFlow = _balanceHidden @@ -59,7 +58,7 @@ class WalletViewModel( val fiatRate: Double? get() = fiatCache.currentRate val fiatCurrency: String? get() = _selectedCurrency.value - val fiatSatsPerUnit: StateFlow = balanceState + val fiatSatsPerUnit: StateFlow = 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 { fiatCache.getCurrencies()?.let { return it } @@ -228,39 +154,19 @@ 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) { - val rs = _receiveState.value - if (rs is ReceiveState.InvoiceReady) { - Log.d(TAG, "BALANCE [WS RECEIVE] Marking invoice ${rs.paymentHash.take(8)}… as received") - _receiveState.value = ReceiveState.PaymentReceived( - amountSats = event.amountSats, - memo = event.memo, - checkingId = rs.paymentHash - ) - } + 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") + _receiveState.value = ReceiveState.PaymentReceived( + amountSats = event.amountSats, + memo = event.memo, + checkingId = rs.paymentHash + ) } } } } - - 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 ?: "", 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 92b6e55..df59f3c 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -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 create(modelClass: Class): 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 } } \ 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 new file mode 100644 index 0000000..eeef129 --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/balance/BalanceViewModel.kt @@ -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.Loading) + val balanceState: StateFlow = _balanceState + + // 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 + + 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? +) \ No newline at end of file