Skip to content

Commit 4fbe25d

Browse files
authored
Merge pull request #170 from synonymdev/feat/widgets-headlines
Widgets - Headlines
2 parents 265ceb2 + 3b286a2 commit 4fbe25d

File tree

14 files changed

+664
-20
lines changed

14 files changed

+664
-20
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package to.bitkit.data
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.core.DataStoreFactory
6+
import androidx.datastore.dataStoreFile
7+
import dagger.hilt.android.qualifiers.ApplicationContext
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.map
10+
import kotlinx.serialization.Serializable
11+
import to.bitkit.data.dto.ArticleDTO
12+
import to.bitkit.data.serializers.WidgetsSerializer
13+
import to.bitkit.utils.Logger
14+
import javax.inject.Inject
15+
import javax.inject.Singleton
16+
17+
@Singleton
18+
class WidgetsStore @Inject constructor(
19+
@ApplicationContext private val context: Context,
20+
) {
21+
private val store: DataStore<WidgetsData> = DataStoreFactory.create(
22+
serializer = WidgetsSerializer,
23+
produceFile = { context.dataStoreFile("widgets.json") },
24+
)
25+
26+
val data: Flow<WidgetsData> = store.data
27+
val articlesFlow: Flow<List<ArticleDTO>> = data.map { it.articles }
28+
29+
suspend fun updateArticles(articles: List<ArticleDTO>) {
30+
store.updateData {
31+
it.copy(articles = articles)
32+
}
33+
}
34+
35+
suspend fun reset() {
36+
store.updateData { WidgetsData() }
37+
Logger.info("Deleted all widgets data.")
38+
}
39+
}
40+
41+
@Serializable
42+
data class WidgetsData(
43+
val articles: List<ArticleDTO> = emptyList(),
44+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package to.bitkit.data.dto
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class ArticleDTO(
7+
val title: String,
8+
val publishedDate: String,
9+
val link: String,
10+
val publisher: PublisherDTO
11+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package to.bitkit.data.dto
2+
3+
import kotlinx.serialization.Serializable
4+
5+
@Serializable
6+
data class PublisherDTO(
7+
val title: String,
8+
)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package to.bitkit.data.serializers
2+
3+
import androidx.datastore.core.Serializer
4+
import kotlinx.serialization.SerializationException
5+
import to.bitkit.data.SettingsData
6+
import to.bitkit.data.WidgetsData
7+
import to.bitkit.di.json
8+
import to.bitkit.utils.Logger
9+
import java.io.InputStream
10+
import java.io.OutputStream
11+
12+
object WidgetsSerializer : Serializer<WidgetsData> {
13+
override val defaultValue: WidgetsData = WidgetsData()
14+
15+
override suspend fun readFrom(input: InputStream): WidgetsData {
16+
return try {
17+
json.decodeFromString(input.readBytes().decodeToString())
18+
} catch (e: SerializationException) {
19+
Logger.error("Failed to deserialize settings: $e")
20+
defaultValue
21+
}
22+
}
23+
24+
override suspend fun writeTo(t: WidgetsData, output: OutputStream) {
25+
output.write(json.encodeToString(t).encodeToByteArray())
26+
}
27+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 to.bitkit.data.dto.ArticleDTO
9+
import to.bitkit.env.Env
10+
import to.bitkit.models.WidgetType
11+
import to.bitkit.utils.AppError
12+
import to.bitkit.utils.Logger
13+
import javax.inject.Inject
14+
import javax.inject.Singleton
15+
import kotlin.time.Duration.Companion.minutes
16+
17+
@Singleton
18+
class NewsService @Inject constructor(
19+
private val client: HttpClient,
20+
) : WidgetService<List<ArticleDTO>> {
21+
22+
23+
override val widgetType = WidgetType.NEWS
24+
override val refreshInterval = 10.minutes
25+
26+
override suspend fun fetchData(): Result<List<ArticleDTO>> = runCatching {
27+
get<List<ArticleDTO>>(Env.newsBaseUrl + "/articles").take(10)
28+
}.onFailure {
29+
Logger.warn(e = it, msg = "Failed to fetch news", context = TAG)
30+
}
31+
32+
// Future services can be added here
33+
private suspend inline fun <reified T> get(url: String): T {
34+
val response: HttpResponse = client.get(url)
35+
Logger.debug("Http call: $response")
36+
return when (response.status.isSuccess()) {
37+
true -> {
38+
val responseBody = runCatching { response.body<T>() }.getOrElse {
39+
throw NewsError.InvalidResponse(it.message.orEmpty())
40+
}
41+
responseBody
42+
}
43+
else -> throw NewsError.InvalidResponse(response.status.description)
44+
}
45+
}
46+
47+
companion object {
48+
private const val TAG = "NewsService"
49+
}
50+
}
51+
52+
/**
53+
* News-specific error types
54+
*/
55+
sealed class NewsError(message: String) : AppError(message) {
56+
data class InvalidResponse(override val message: String) : NewsError(message)
57+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package to.bitkit.data.widgets
2+
3+
import to.bitkit.models.WidgetType
4+
5+
interface WidgetService<T> {
6+
val widgetType: WidgetType
7+
suspend fun fetchData(): Result<T>
8+
val refreshInterval: kotlin.time.Duration
9+
}

app/src/main/java/to/bitkit/env/Env.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ internal object Env {
6161
val btcRatesServer get() = "https://blocktank.synonym.to/fx/rates/btc/"
6262
val geoCheckUrl get() = "https://api1.blocktank.to/api/geocheck"
6363
const val chatwootUrl = "https://synonym.to/api/chatwoot"
64+
const val newsBaseUrl = "https://feeds.synonym.to/news-feed/api"
6465

6566
const val fxRateRefreshInterval: Long = 2 * 60 * 1000 // 2 minutes in milliseconds
6667
const val fxRateStaleThreshold: Long = 10 * 60 * 1000 // 10 minutes in milliseconds
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package to.bitkit.models.widget
2+
3+
import kotlinx.serialization.Serializable
4+
import to.bitkit.data.dto.ArticleDTO
5+
import to.bitkit.utils.Logger
6+
import java.time.OffsetDateTime
7+
import java.time.format.DateTimeFormatter
8+
import java.time.format.DateTimeParseException
9+
import java.time.temporal.ChronoUnit
10+
import java.util.Locale
11+
12+
@Serializable
13+
data class ArticleModel(
14+
val title: String,
15+
val timeAgo: String,
16+
val link: String,
17+
val publisher: String
18+
)
19+
20+
fun ArticleDTO.toArticleModel() = ArticleModel(
21+
title = this.title,
22+
timeAgo = timeAgo(this.publishedDate),
23+
link = this.link,
24+
publisher = this.publisher.title
25+
)
26+
27+
/**
28+
* Converts a date string to a human-readable time ago format
29+
* @param dateString Date string in format "EEE, dd MMM yyyy HH:mm:ss Z"
30+
* @return Human-readable time difference (e.g. "5 hours ago")
31+
*/
32+
private fun timeAgo(dateString: String): String {
33+
return try {
34+
val formatters = listOf(
35+
DateTimeFormatter.RFC_1123_DATE_TIME, // Handles "EEE, dd MMM yyyy HH:mm:ss zzz" (like GMT)
36+
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss Z", Locale.ENGLISH) // Handles "+0000"
37+
)
38+
39+
var parsedDateTime: OffsetDateTime? = null
40+
for (formatter in formatters) {
41+
try {
42+
parsedDateTime = OffsetDateTime.parse(dateString, formatter)
43+
break // Successfully parsed, stop trying other formatters
44+
} catch (e: DateTimeParseException) {
45+
// Continue to the next formatter if this one fails
46+
}
47+
}
48+
49+
if (parsedDateTime == null) {
50+
Logger.debug("Failed to parse date: Unparseable date: $dateString")
51+
return ""
52+
}
53+
54+
val now = OffsetDateTime.now()
55+
56+
val diffMinutes = ChronoUnit.MINUTES.between(parsedDateTime, now)
57+
val diffHours = ChronoUnit.HOURS.between(parsedDateTime, now)
58+
val diffDays = ChronoUnit.DAYS.between(parsedDateTime, now)
59+
val diffMonths = ChronoUnit.MONTHS.between(parsedDateTime, now)
60+
61+
return when {
62+
diffMinutes < 1 -> "just now"
63+
diffMinutes < 60 -> "$diffMinutes minutes ago"
64+
diffHours < 24 -> "$diffHours hours ago"
65+
diffDays < 30 -> "$diffDays days ago" // Approximate for months
66+
else -> "$diffMonths months ago"
67+
}
68+
} catch (e: Exception) {
69+
Logger.warn("An unexpected error occurred while parsing date: ${e.message}")
70+
""
71+
}
72+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package to.bitkit.repositories
2+
3+
import kotlinx.coroutines.CoroutineDispatcher
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.SupervisorJob
6+
import kotlinx.coroutines.delay
7+
import kotlinx.coroutines.flow.Flow
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.coroutines.flow.asStateFlow
11+
import kotlinx.coroutines.flow.map
12+
import kotlinx.coroutines.flow.update
13+
import kotlinx.coroutines.launch
14+
import kotlinx.coroutines.withContext
15+
import to.bitkit.data.WidgetsStore
16+
import to.bitkit.data.widgets.NewsService
17+
import to.bitkit.data.widgets.WidgetService
18+
import to.bitkit.di.BgDispatcher
19+
import to.bitkit.models.WidgetType
20+
import to.bitkit.utils.Logger
21+
import javax.inject.Inject
22+
import javax.inject.Singleton
23+
import kotlin.time.Duration.Companion.minutes
24+
25+
@Singleton
26+
class WidgetsRepo @Inject constructor(
27+
@BgDispatcher private val bgDispatcher: CoroutineDispatcher,
28+
private val newsService: NewsService,
29+
private val widgetsStore: WidgetsStore
30+
) {
31+
private val repoScope = CoroutineScope(bgDispatcher + SupervisorJob())
32+
33+
val widgetsDataFlow = widgetsStore.data
34+
val articlesFlow = widgetsStore.articlesFlow
35+
36+
private val _refreshStates = MutableStateFlow(
37+
WidgetType.entries.associateWith { false }
38+
)
39+
val refreshStates: StateFlow<Map<WidgetType, Boolean>> = _refreshStates.asStateFlow()
40+
41+
init {
42+
startPeriodicUpdates()
43+
}
44+
45+
/**
46+
* Start periodic updates for all widgets
47+
*/
48+
private fun startPeriodicUpdates() {
49+
startPeriodicUpdate(newsService) { articles ->
50+
widgetsStore.updateArticles(articles)
51+
}
52+
}
53+
54+
/**
55+
* Generic method to start periodic updates for any widget service
56+
*/
57+
private fun <T> startPeriodicUpdate(
58+
service: WidgetService<T>,
59+
updateStore: suspend (T) -> Unit
60+
) {
61+
repoScope.launch {
62+
while (true) {
63+
updateWidget(service, updateStore)
64+
delay(service.refreshInterval)
65+
}
66+
}
67+
}
68+
69+
/**
70+
* Update a specific widget type
71+
*/
72+
private suspend fun <T> updateWidget(
73+
service: WidgetService<T>,
74+
updateStore: suspend (T) -> Unit
75+
) {
76+
val widgetType = service.widgetType
77+
_refreshStates.update { it + (widgetType to true) }
78+
79+
service.fetchData()
80+
.onSuccess { data ->
81+
updateStore(data)
82+
Logger.debug("Updated $widgetType widget successfully")
83+
}
84+
.onFailure { error ->
85+
Logger.warn(e = error, msg = "Failed to update $widgetType widget", context = TAG)
86+
}
87+
88+
_refreshStates.update { it + (widgetType to false) }
89+
}
90+
91+
/**
92+
* Manually refresh all widgets
93+
*/
94+
suspend fun refreshAllWidgets(): Result<Unit> = runCatching {
95+
listOf(
96+
updateWidget(newsService) { articles ->
97+
widgetsStore.updateArticles(articles)
98+
},
99+
)
100+
}
101+
102+
/**
103+
* Manually refresh specific widget
104+
*/
105+
suspend fun refreshWidget(widgetType: WidgetType): Result<Unit> = runCatching {
106+
when (widgetType) {
107+
WidgetType.NEWS -> updateWidget(newsService) { articles ->
108+
widgetsStore.updateArticles(articles)
109+
}
110+
WidgetType.WEATHER -> {
111+
// TODO: Implement when PriceService is ready
112+
throw NotImplementedError("Weather widget not implemented yet")
113+
}
114+
WidgetType.PRICE -> {
115+
// TODO: Implement when PriceService is ready
116+
throw NotImplementedError("Price widget not implemented yet")
117+
}
118+
WidgetType.BLOCK -> {
119+
// TODO: Implement when BlockService is ready
120+
throw NotImplementedError("Block widget not implemented yet")
121+
}
122+
WidgetType.CALCULATOR -> {
123+
// TODO: Implement when CalculatorService is ready
124+
throw NotImplementedError("Calculator widget not implemented yet")
125+
}
126+
WidgetType.FACTS -> {
127+
// TODO: Implement when FactsService is ready
128+
throw NotImplementedError("Facts widget not implemented yet")
129+
}
130+
}
131+
}
132+
133+
/**
134+
* Get refresh state for a specific widget type
135+
*/
136+
fun getRefreshState(widgetType: WidgetType): Flow<Boolean> {
137+
return refreshStates.map { it[widgetType] ?: false }
138+
}
139+
140+
/**
141+
* Check if a widget type is currently supported
142+
*/
143+
fun isWidgetSupported(widgetType: WidgetType): Boolean {
144+
return when (widgetType) {
145+
WidgetType.NEWS, WidgetType.WEATHER -> true
146+
else -> false
147+
}
148+
}
149+
150+
companion object {
151+
private const val TAG = "WidgetsRepo"
152+
}
153+
}

0 commit comments

Comments
 (0)