Skip to content

Commit cc01ff2

Browse files
authored
Merge pull request #186 from synonymdev/feat/widget-prices
Widget - Prices
2 parents 6bb5167 + 8a6cfe7 commit cc01ff2

File tree

22 files changed

+1433
-11
lines changed

22 files changed

+1433
-11
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ dependencies {
179179
implementation(libs.constraintlayout.compose)
180180

181181
implementation(libs.lottie)
182+
implementation(libs.charts)
182183

183184
// Compose Navigation
184185
implementation(libs.navigation.compose)

app/src/main/java/to/bitkit/data/WidgetsStore.kt

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import kotlinx.serialization.Serializable
1212
import to.bitkit.data.dto.ArticleDTO
1313
import to.bitkit.data.dto.BlockDTO
1414
import to.bitkit.data.dto.WeatherDTO
15+
import to.bitkit.data.dto.price.PriceDTO
1516
import to.bitkit.data.serializers.WidgetsSerializer
1617
import to.bitkit.models.WidgetType
1718
import to.bitkit.models.WidgetWithPosition
1819
import to.bitkit.models.widget.BlocksPreferences
1920
import to.bitkit.models.widget.FactsPreferences
2021
import to.bitkit.models.widget.HeadlinePreferences
22+
import to.bitkit.models.widget.PricePreferences
2123
import to.bitkit.models.widget.WeatherPreferences
2224
import to.bitkit.utils.Logger
2325
import javax.inject.Inject
@@ -37,6 +39,7 @@ class WidgetsStore @Inject constructor(
3739
val factsFlow: Flow<List<String>> = data.map { it.facts }
3840
val blocksFlow: Flow<BlockDTO?> = data.map { it.block }
3941
val weatherFlow: Flow<WeatherDTO?> = data.map { it.weather }
42+
val priceFlow: Flow<PriceDTO?> = data.map { it.price }
4043

4144
suspend fun updateArticles(articles: List<ArticleDTO>) {
4245
store.updateData {
@@ -68,6 +71,18 @@ class WidgetsStore @Inject constructor(
6871
}
6972
}
7073

74+
suspend fun updatePricePreferences(preferences: PricePreferences) {
75+
store.updateData { currentStore ->
76+
currentStore.copy(
77+
pricePreferences = preferences.copy(
78+
enabledPairs = preferences.enabledPairs.sortedBy { tradingPair ->
79+
tradingPair.position
80+
}
81+
)
82+
)
83+
}
84+
}
85+
7186
suspend fun updateFacts(facts: List<String>) {
7287
store.updateData {
7388
it.copy(facts = facts)
@@ -86,21 +101,27 @@ class WidgetsStore @Inject constructor(
86101
}
87102
}
88103

104+
suspend fun updatePrice(price: PriceDTO) {
105+
store.updateData {
106+
it.copy(price = price)
107+
}
108+
}
109+
89110
suspend fun reset() {
90111
store.updateData { WidgetsData() }
91112
Logger.info("Deleted all widgets data.")
92113
}
93114

94115
suspend fun addWidget(type: WidgetType) {
95-
if(store.data.first().widgets.map { it.type }.contains(type)) return
116+
if (store.data.first().widgets.map { it.type }.contains(type)) return
96117

97118
store.updateData {
98119
it.copy(widgets = (it.widgets + WidgetWithPosition(type = type)).sortedBy { it.position })
99120
}
100121
}
101122

102123
suspend fun deleteWidget(type: WidgetType) {
103-
if(!store.data.first().widgets.map { it.type }.contains(type)) return
124+
if (!store.data.first().widgets.map { it.type }.contains(type)) return
104125

105126
store.updateData {
106127
it.copy(widgets = it.widgets.filterNot { it.type == type })
@@ -121,8 +142,10 @@ data class WidgetsData(
121142
val factsPreferences: FactsPreferences = FactsPreferences(),
122143
val blocksPreferences: BlocksPreferences = BlocksPreferences(),
123144
val weatherPreferences: WeatherPreferences = WeatherPreferences(),
145+
val pricePreferences: PricePreferences = PricePreferences(),
124146
val articles: List<ArticleDTO> = emptyList(),
125147
val facts: List<String> = emptyList(),
126148
val block: BlockDTO? = null,
127149
val weather: WeatherDTO? = null,
150+
val price: PriceDTO? = null,
128151
)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package to.bitkit.data.dto.price
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class CandleResponse(
7+
val timestamp: Long,
8+
val open: Double,
9+
val close: Double,
10+
val high: Double,
11+
val low: Double,
12+
val volume: Double
13+
)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package to.bitkit.data.dto.price
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
7+
data class Change(
8+
val isPositive: Boolean,
9+
val formatted: String
10+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package to.bitkit.data.dto.price
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
enum class GraphPeriod(val value: String) {
7+
ONE_DAY("1D"),
8+
ONE_WEEK("1W"),
9+
ONE_MONTH("1M"),
10+
ONE_YEAR("1Y")
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package to.bitkit.data.dto.price
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class PriceDTO(
7+
val widgets: List<PriceWidgetData>,
8+
val source: String
9+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package to.bitkit.data.dto.price
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class PriceResponse(
7+
val price: Double,
8+
val timestamp: Long
9+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package to.bitkit.data.dto.price
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class PriceWidgetData(
7+
val pair: TradingPair,
8+
val period: GraphPeriod,
9+
val change: Change,
10+
val price: String,
11+
val pastValues: List<Double>
12+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package to.bitkit.data.dto.price
2+
3+
enum class TradingPair(
4+
val displayName: String,
5+
val base: String,
6+
val quote: String,
7+
val symbol: String,
8+
val position: Int,
9+
) {
10+
BTC_USD(displayName = "BTC/USD", base = "BTC", quote = "USD", symbol = "$", position = 0),
11+
BTC_EUR(displayName = "BTC/EUR", base = "BTC", quote = "EUR", symbol = "", position = 1),
12+
BTC_GBP(displayName = "BTC/GBP", base = "BTC", quote = "GBP", symbol = "£", position = 2),
13+
BTC_JPY(displayName = "BTC/JPY", base = "BTC", quote = "JPY", symbol = "¥", position = 3);
14+
15+
val ticker: String
16+
get() = "$base$quote"
17+
}
18+
19+
fun String.displayNameToTradingPair() = TradingPair.entries.firstOrNull { it.displayName == this }
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package to.bitkit.data.widgets
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.call.body
5+
import io.ktor.client.request.get
6+
import io.ktor.client.statement.HttpResponse
7+
import io.ktor.http.isSuccess
8+
import kotlinx.coroutines.async
9+
import kotlinx.coroutines.awaitAll
10+
import kotlinx.coroutines.coroutineScope
11+
import kotlinx.coroutines.flow.first
12+
import to.bitkit.data.WidgetsStore
13+
import to.bitkit.data.dto.price.CandleResponse
14+
import to.bitkit.data.dto.price.Change
15+
import to.bitkit.data.dto.price.GraphPeriod
16+
import to.bitkit.data.dto.price.PriceDTO
17+
import to.bitkit.data.dto.price.PriceResponse
18+
import to.bitkit.data.dto.price.PriceWidgetData
19+
import to.bitkit.data.dto.price.TradingPair
20+
import to.bitkit.env.Env
21+
import to.bitkit.models.WidgetType
22+
import to.bitkit.utils.AppError
23+
import to.bitkit.utils.Logger
24+
import java.text.NumberFormat
25+
import java.util.Currency
26+
import java.util.Locale
27+
import javax.inject.Inject
28+
import javax.inject.Singleton
29+
import kotlin.time.Duration.Companion.minutes
30+
31+
32+
@Singleton
33+
class PriceService @Inject constructor(
34+
private val client: HttpClient,
35+
private val widgetsStore: WidgetsStore,
36+
) : WidgetService<PriceDTO> {
37+
38+
override val widgetType = WidgetType.PRICE
39+
override val refreshInterval = 1.minutes
40+
private val sourceLabel = "Bitfinex.com"
41+
42+
override suspend fun fetchData(): Result<PriceDTO> = runCatching {
43+
val period = widgetsStore.data.first().pricePreferences.period ?: GraphPeriod.ONE_DAY
44+
45+
val widgets = TradingPair.entries.map { pair ->
46+
fetchPairData(pair = pair, period = period)
47+
}
48+
PriceDTO(widgets = widgets, source = sourceLabel)
49+
}.onFailure {
50+
Logger.warn(e = it, msg = "Failed to fetch price data", context = TAG)
51+
}
52+
53+
suspend fun fetchAllPeriods(): Result<List<PriceDTO>> = runCatching {
54+
coroutineScope {
55+
GraphPeriod.entries.map { period ->
56+
async {
57+
PriceDTO(
58+
widgets = TradingPair.entries.map { pair ->
59+
fetchPairData(pair = pair, period = period)
60+
},
61+
source = sourceLabel
62+
)
63+
}
64+
}.awaitAll()
65+
}
66+
}.onFailure {
67+
Logger.warn(e = it, msg = "fetchAllPeriods: Failed to fetch price data", context = TAG)
68+
}
69+
70+
71+
private suspend fun fetchPairData(pair: TradingPair, period: GraphPeriod): PriceWidgetData {
72+
val ticker = pair.ticker
73+
74+
// Fetch historical candles
75+
val candles = fetchCandles(ticker = ticker, period = period)
76+
val sortedCandles = candles.sortedBy { it.timestamp }
77+
val pastValues = sortedCandles.map { it.close }.toMutableList()
78+
79+
// Fetch latest price and replace last candle value
80+
val latestPrice = fetchLatestPrice(ticker)
81+
if (pastValues.isNotEmpty()) {
82+
pastValues[pastValues.size - 1] = latestPrice
83+
} else {
84+
pastValues.add(latestPrice)
85+
}
86+
87+
val change = calculateChange(pastValues)
88+
val formattedPrice = formatPrice(pair, latestPrice)
89+
90+
return PriceWidgetData(
91+
pair = pair,
92+
change = change,
93+
price = formattedPrice,
94+
period = period,
95+
pastValues = pastValues
96+
)
97+
}
98+
99+
private suspend fun fetchLatestPrice(ticker: String): Double {
100+
val response: HttpResponse = client.get("${Env.pricesWidgetBaseUrl}/price/$ticker/latest")
101+
return when (response.status.isSuccess()) {
102+
true -> {
103+
val priceResponse = runCatching { response.body<PriceResponse>() }.getOrElse {
104+
throw PriceError.InvalidResponse("Failed to parse price response: ${it.message}")
105+
}
106+
priceResponse.price
107+
}
108+
109+
else -> throw PriceError.InvalidResponse("Failed to fetch latest price: ${response.status.description}")
110+
}
111+
}
112+
113+
private suspend fun fetchCandles(
114+
ticker: String,
115+
period: GraphPeriod
116+
): List<CandleResponse> {
117+
val response: HttpResponse = client.get(
118+
"${Env.pricesWidgetBaseUrl}/price/$ticker/history/${period.value}"
119+
)
120+
return when (response.status.isSuccess()) {
121+
true -> {
122+
runCatching { response.body<List<CandleResponse>>() }.getOrElse {
123+
throw PriceError.InvalidResponse("Failed to parse candles response: ${it.message}")
124+
}
125+
}
126+
127+
else -> throw PriceError.InvalidResponse("Failed to fetch candles: ${response.status.description}")
128+
}
129+
}
130+
131+
private fun calculateChange(pastValues: List<Double>): Change {
132+
if (pastValues.size < 2) {
133+
return Change(isPositive = true, formatted = "+0%")
134+
}
135+
136+
val firstValue = pastValues.first()
137+
val lastValue = pastValues.last()
138+
val changeRatio = (lastValue / firstValue) - 1
139+
140+
val sign = if (changeRatio >= 0) "+" else ""
141+
val percentage = changeRatio * 100
142+
143+
return Change(
144+
isPositive = changeRatio >= 0,
145+
formatted = "$sign${"%.2f".format(percentage)}%"
146+
)
147+
}
148+
149+
private fun formatPrice(pair: TradingPair, price: Double): String {
150+
return try {
151+
val currency = Currency.getInstance(pair.quote)
152+
val numberFormat = NumberFormat.getCurrencyInstance(Locale.US).apply {
153+
this.currency = currency
154+
maximumFractionDigits = when {
155+
price >= 1000 -> 0
156+
price >= 1 -> 2
157+
else -> 6
158+
}
159+
}
160+
161+
// Format and remove currency symbol, keeping only the number with formatting
162+
val formatted = numberFormat.format(price)
163+
val currencySymbol = currency.symbol
164+
formatted.replace(currencySymbol, "").trim()
165+
166+
} catch (e: Exception) {
167+
Logger.warn(
168+
e = e,
169+
msg = "Error formatting price for ${pair.displayName}",
170+
context = TAG
171+
)
172+
String.format("%.2f", price)
173+
}
174+
}
175+
176+
companion object {
177+
private const val TAG = "PriceService"
178+
}
179+
}
180+
181+
/**
182+
* Price-specific error types
183+
*/
184+
sealed class PriceError(message: String) : AppError(message) {
185+
data class InvalidResponse(override val message: String) : PriceError(message)
186+
data class NetworkError(override val message: String) : PriceError(message)
187+
}

0 commit comments

Comments
 (0)