Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 7 additions & 10 deletions app/src/main/java/to/bitkit/data/widgets/WeatherService.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,9 +11,11 @@ import to.bitkit.data.dto.FeeEstimates
import to.bitkit.data.dto.WeatherDTO
import to.bitkit.env.Env
import to.bitkit.models.WidgetType
import to.bitkit.repositories.CurrencyRepo
import to.bitkit.services.CurrencyService
import to.bitkit.utils.AppError
import to.bitkit.utils.Logger
import java.math.BigDecimal
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.floor
Expand All@@ -23,7 +25,7 @@ import kotlin.time.Duration.Companion.minutes
@Singleton
class WeatherService @Inject constructor(
private val client: HttpClient,
private val currencyService: CurrencyService,
private val currencyRepo: CurrencyRepo,
) : WidgetService<WeatherDTO> {

override val widgetType = WidgetType.WEATHER
Expand DownExpand Up@@ -92,9 +94,9 @@ class WeatherService @Inject constructor(

// Check USD threshold first
val avgFeeSats = currentFeeRate * AVERAGE_SEGWIT_VBYTES_SIZE
val avgFeeUsd = currencyService.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY)
val avgFeeUsd = currencyRepo.convertSatsToFiat(avgFeeSats.toLong(), currency = USD_CURRENCY).getOrNull() ?: return FeeCondition.AVERAGE

if (avgFeeUsd <= USD_GOOD_THRESHOLD) {
if (avgFeeUsd.value <= BigDecimal(USD_GOOD_THRESHOLD)) {
return FeeCondition.GOOD
}

Expand All@@ -107,13 +109,8 @@ class WeatherService @Inject constructor(
}

private suspend fun formatFeeForDisplay(satoshis: Int): String {
val usdValue = convertSatsToUsd(satoshis)
return "$ ${String.format("%.2f", usdValue)}"
}

private suspend fun convertSatsToUsd(satoshis: Int): Double {
val amountInUsd = currencyService.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY)
return amountInUsd
val usdValue = currencyRepo.convertSatsToFiat(satoshis.toLong(), currency = USD_CURRENCY).getOrNull()
return usdValue?.formatted.orEmpty()
}
}
/**
Expand Down
17 changes: 17 additions & 0 deletions app/src/main/java/to/bitkit/di/EnablePolling.kt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
package to.bitkit.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named


@Module
@InstallIn(SingletonComponent::class)
object CurrencyModule {

@Provides
@Named("enablePolling")
fun provideEnablePolling(): Boolean = true
}
6 changes: 6 additions & 0 deletions app/src/main/java/to/bitkit/di/EnvModule.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.datetime.Clock
import org.lightningdevkit.ldknode.Network
import to.bitkit.env.Env

Expand All@@ -17,4 +18,9 @@ object EnvModule {
fun provideNetwork(): Network {
return Env.network
}

@Provides
fun provideClock(): Clock {
return Clock.System
}
}
149 changes: 110 additions & 39 deletions app/src/main/java/to/bitkit/repositories/CurrencyRepo.kt
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import to.bitkit.data.CacheStore
import to.bitkit.data.SettingsStore
import to.bitkit.di.BgDispatcher
Expand All@@ -26,30 +27,35 @@ import to.bitkit.models.BitcoinDisplayUnit
import to.bitkit.models.ConvertedAmount
import to.bitkit.models.FxRate
import to.bitkit.models.PrimaryDisplay
import to.bitkit.models.SATS_IN_BTC
import to.bitkit.models.Toast
import to.bitkit.services.CurrencyService
import to.bitkit.ui.shared.toast.ToastEventBus
import to.bitkit.ui.utils.formatCurrency
import to.bitkit.utils.Logger
import java.util.Date
import java.math.BigDecimal
import java.math.RoundingMode
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton

@Singleton
class CurrencyRepo @Inject constructor(
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
private val currencyService: CurrencyService,
private val settingsStore: SettingsStore,
private val cacheStore: CacheStore
private val cacheStore: CacheStore,
@Named("enablePolling") private val enablePolling: Boolean,
private val clock: Clock,
) {
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())

private val _currencyState = MutableStateFlow(CurrencyState())
val currencyState: StateFlow<CurrencyState> = _currencyState.asStateFlow()

private var lastSuccessfulRefresh: Date? = null
@Volatile
private var isRefreshing = false

private val pollingFlow: Flow<Unit>
private val fxRatePollingFlow: Flow<Unit>
get() = flow {
while (currentCoroutineContext().isActive) {
emit(Unit)
Expand All@@ -58,44 +64,55 @@ class CurrencyRepo @Inject constructor(
}.flowOn(bgDispatcher)

init {
startPolling()
if (enablePolling) {
startPolling()
}
observeStaleData()
collectCachedData()
}

private fun startPolling() {
repoScope.launch {
pollingFlow.collect {
fxRatePollingFlow.collect {
refresh()
}
}
}

private fun observeStaleData() {
repoScope.launch {
currencyState.map { it.hasStaleData }.distinctUntilChanged().collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
currencyState
.map { it.hasStaleData }
.distinctUntilChanged()
.collect { isStale ->
if (isStale) {
ToastEventBus.send(
type = Toast.ToastType.ERROR,
title = "Rates currently unavailable",
description = "An error has occurred. Please try again later."
)
}
}
}
}
}

private fun collectCachedData() {
repoScope.launch {
combine(settingsStore.data, cacheStore.data) { settings, cachedData ->
combine(
settingsStore.data.distinctUntilChanged(),
cacheStore.data.distinctUntilChanged()
) { settings, cachedData ->
val selectedRate = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}
_currencyState.value.copy(
rates = cachedData.cachedRates,
selectedCurrency = settings.selectedCurrency,
displayUnit = settings.displayUnit,
primaryDisplay = settings.primaryDisplay,
currencySymbol = cachedData.cachedRates.firstOrNull { rate ->
rate.quote == settings.selectedCurrency
}?.currencySymbol ?: "$"
currencySymbol = selectedRate?.currencySymbol ?: "$",
error = null,
hasStaleData = false
)
}.collect { newState ->
_currencyState.update { newState }
Expand All@@ -116,29 +133,32 @@ class CurrencyRepo @Inject constructor(
_currencyState.update {
it.copy(
error = null,
hasStaleData = false
hasStaleData = false,
lastSuccessfulRefresh = clock.now().toEpochMilliseconds(),
)
}
lastSuccessfulRefresh = Date()
Logger.debug("Currency rates refreshed successfully", context = TAG)
} catch (e: Exception) {
_currencyState.update { it.copy(error = e) }
Logger.error("Currency rates refresh failed", e, context = TAG)
_currencyState.update { it.copy(error = e) }

lastSuccessfulRefresh?.let { last ->
_currencyState.update {
it.copy(hasStaleData = Date().time - last.time > Env.fxRateStaleThreshold)
}
_currencyState.value.lastSuccessfulRefresh?.let { lastUpdatedAt ->
val isStale = clock.now().toEpochMilliseconds() - lastUpdatedAt > Env.fxRateStaleThreshold
_currencyState.update { it.copy(hasStaleData = isStale) }
}
} finally {
isRefreshing = false
}
}

suspend fun togglePrimaryDisplay() = withContext(bgDispatcher) {
currencyState.value.primaryDisplay.let {
val newDisplay = if (it == PrimaryDisplay.BITCOIN) PrimaryDisplay.FIAT else PrimaryDisplay.BITCOIN
settingsStore.update { it.copy(primaryDisplay = newDisplay) }
settingsStore.update { settings ->
val newDisplay = if (settings.primaryDisplay == PrimaryDisplay.BITCOIN) {
PrimaryDisplay.FIAT
} else {
PrimaryDisplay.BITCOIN
}
settings.copy(primaryDisplay = newDisplay)
}
}

Expand All@@ -156,24 +176,74 @@ class CurrencyRepo @Inject constructor(
}

fun getCurrencySymbol(): String {
val currentState = currencyState.value
return currentState.rates.firstOrNull { it.quote == currentState.selectedCurrency }?.currencySymbol ?: ""
return _currencyState.value.currencySymbol
}

// Conversion helpers
fun convertSatsToFiat(sats: Long, currency: String? = null): ConvertedAmount? {
val targetCurrency = currency ?: currencyState.value.selectedCurrency
val rate = currencyService.getCurrentRate(targetCurrency, currencyState.value.rates)
return rate?.let { currencyService.convert(sats = sats, rate = it) }
fun getCurrentRate(currency: String): FxRate? {
return _currencyState.value.rates.firstOrNull { it.quote == currency }
}

fun convertSatsToFiat(
sats: Long,
currency: String? = null,
): Result<ConvertedAmount> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: return Result.failure(
IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)
)

val btcAmount = BigDecimal(sats).divide(BigDecimal(SATS_IN_BTC), BTC_SCALE, RoundingMode.HALF_UP)
val value = btcAmount.multiply(BigDecimal.valueOf(rate.rate))
val formatted = value.formatCurrency() ?: return Result.failure(
IllegalStateException(
"Failed to format value: $value for currency: $targetCurrency"
)
)

ConvertedAmount(
value = value,
formatted = formatted,
symbol = rate.currencySymbol,
currency = rate.quote,
flag = rate.currencyFlag,
sats = sats,
)
}

fun convertFiatToSats(
fiatValue: BigDecimal,
currency: String? = null,
): Result<ULong> = runCatching {
val targetCurrency = currency ?: _currencyState.value.selectedCurrency
val rate = getCurrentRate(targetCurrency) ?: throw IllegalStateException(
"Rate not found for currency: $targetCurrency. Available currencies: ${
_currencyState.value.rates.joinToString { it.quote }
}"
)

val btcAmount = fiatValue.divide(BigDecimal.valueOf(rate.rate), BTC_SCALE, RoundingMode.HALF_UP)
val satsDecimal = btcAmount.multiply(BigDecimal(SATS_IN_BTC))
val roundedSats = satsDecimal.setScale(0, RoundingMode.HALF_UP)
roundedSats.toLong().toULong()
}

fun convertFiatToSats(fiatAmount: Double, currency: String? = null): Long {
val sourceCurrency = currency ?: currencyState.value.selectedCurrency
return currencyService.convertFiatToSats(fiatAmount, sourceCurrency, currencyState.value.rates)
fun convertFiatToSats(
fiatAmount: Double,
currency: String?
): Result<ULong> {
return convertFiatToSats(
fiatValue = BigDecimal.valueOf(fiatAmount),
currency = currency
)
}

companion object {
private const val TAG = "CurrencyRepo"
private const val BTC_SCALE = 8
}
}

Expand All@@ -185,4 +255,5 @@ data class CurrencyState(
val currencySymbol: String = "$",
val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN,
val primaryDisplay: PrimaryDisplay = PrimaryDisplay.BITCOIN,
val lastSuccessfulRefresh: Long? = null
)
Loading