diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cecd21bf..b4c208ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -58,6 +58,7 @@ dependencies { implementation("androidx.activity:activity-compose:1.8.2") implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material:material:1.5.4") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.core:core-ktx:1.12.0") diff --git a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt index 2ed8217d..9bf0a30a 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/accounts/AccountViewModel.kt @@ -73,7 +73,13 @@ class AccountViewModel( onComplete() } - suspend fun selectArticle(articleID: String) { + suspend fun refreshFeed() { + val id = feedID.value ?: return + + account?.refreshFeed(id) + } + + fun selectArticle(articleID: String) { articleState.value = account?.findArticle(articleID.toLong(), feedID.value?.toLongOrNull()) } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt index 97017bdb..d8b888ff 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleList.kt @@ -1,13 +1,21 @@ package com.jocmp.basilreader.ui.articles import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -16,30 +24,48 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.jocmp.basil.Article import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterialApi::class) @Composable fun ArticleList( pager: Pager, + onRefresh: suspend () -> Unit, onSelect: suspend (articleID: String) -> Unit, ) { val composableScope = rememberCoroutineScope() val lazyPagingItems = pager.flow.collectAsLazyPagingItems() - LazyColumn(Modifier.fillMaxWidth()) { - items(count = lazyPagingItems.itemCount) { index -> - val item = lazyPagingItems[index] - Column( - modifier = Modifier - .padding(8.dp) - .clickable { - item?.let { - composableScope.launch { - onSelect(it.id) + val refreshScope = rememberCoroutineScope() + val (refreshing, setRefreshing) = remember { mutableStateOf(false) } + + fun refresh() = refreshScope.launch { + setRefreshing(true) + onRefresh() + setRefreshing(false) + } + + val state = rememberPullRefreshState(refreshing, ::refresh) + + Box(Modifier.pullRefresh(state)) { + + LazyColumn(Modifier.fillMaxWidth()) { + items(count = lazyPagingItems.itemCount) { index -> + val item = lazyPagingItems[index] + Column( + modifier = Modifier + .padding(8.dp) + .clickable { + item?.let { + composableScope.launch { + onSelect(it.id) + } } } - } - ) { - Text(item?.title ?: "No title", fontSize = 20.sp) + ) { + Text(item?.title ?: "No title", fontSize = 20.sp) + } } } + + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) } } diff --git a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt index 6c5e85f5..ec845942 100644 --- a/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt +++ b/app/src/main/java/com/jocmp/basilreader/ui/articles/ArticleScreen.kt @@ -51,6 +51,9 @@ fun ArticleScreen( viewModel.articles()?.let { pager -> ArticleList( pager = pager, + onRefresh = { + viewModel.refreshFeed() + }, onSelect = { viewModel.selectArticle(it) navigateToDetail() diff --git a/basil/src/main/java/com/jocmp/basil/Account.kt b/basil/src/main/java/com/jocmp/basil/Account.kt index 05fb0617..3b23bb2d 100644 --- a/basil/src/main/java/com/jocmp/basil/Account.kt +++ b/basil/src/main/java/com/jocmp/basil/Account.kt @@ -86,17 +86,17 @@ data class Account( coroutineScope { launch { val items = delegate.fetchAll(feed) - val feedID = feed.id.toLong() items.forEach { item -> database.articlesQueries.create( - feed_id = feedID, + feed_id = feed.primaryKey, external_id = item.externalID, title = item.title, content_html = item.contentHTML, url = item.url, summary = item.summary, - image_url = item.imageURL + image_url = item.imageURL, + published_at = item.publishedAt?.toEpochSecond() ) } } @@ -124,7 +124,26 @@ data class Account( return feed } - suspend fun findArticle(articleID: Long?, feedID: Long?): Article? { + suspend fun refreshFeed(feedID: String) { + val feed = flattenedFeeds.find { it.id == feedID } ?: return + + val items = delegate.fetchAll(feed) + + items.forEach { item -> + database.articlesQueries.create( + feed_id = feed.primaryKey, + external_id = item.externalID, + title = item.title, + content_html = item.contentHTML, + url = item.url, + summary = item.summary, + image_url = item.imageURL, + published_at = item.publishedAt?.toEpochSecond() + ) + } + } + + fun findArticle(articleID: Long?, feedID: Long?): Article? { articleID ?: return null return database.articlesQueries.findBy( diff --git a/basil/src/main/java/com/jocmp/basil/Feed.kt b/basil/src/main/java/com/jocmp/basil/Feed.kt index a745d164..e72127de 100644 --- a/basil/src/main/java/com/jocmp/basil/Feed.kt +++ b/basil/src/main/java/com/jocmp/basil/Feed.kt @@ -9,6 +9,9 @@ data class Feed( val feedURL: String, val siteURL: String = "" ) { + internal val primaryKey: Long + get() = id.toLong() + override fun equals(other: Any?): Boolean { if (other is Feed) { return id == other.id diff --git a/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt b/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt index 1eb2649e..d0b67a60 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/LocalAccountDelegate.kt @@ -3,9 +3,11 @@ package com.jocmp.basil.accounts import com.jocmp.basil.Account import com.jocmp.basil.Feed import com.jocmp.basil.feeds.ExternalFeed +import com.jocmp.basil.shared.parseISODate import com.prof18.rssparser.RssParser import com.prof18.rssparser.model.RssItem import java.net.URL +import java.time.ZonedDateTime internal class LocalAccountDelegate(private val account: Account) : AccountDelegate { override suspend fun createFeed(feedURL: URL): ExternalFeed { @@ -36,7 +38,8 @@ internal class LocalAccountDelegate(private val account: Account) : AccountDeleg contentHTML = item.content, url = item.link, summary = item.description, - imageURL = item.image + imageURL = item.image, + publishedAt = parseISODate(item.pubDate) ) } } diff --git a/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt b/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt index ff2a9c2e..e045b187 100644 --- a/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt +++ b/basil/src/main/java/com/jocmp/basil/accounts/ParsedItem.kt @@ -1,5 +1,7 @@ package com.jocmp.basil.accounts +import java.time.OffsetDateTime + internal data class ParsedItem( val externalID: String, val title: String? = null, @@ -7,4 +9,5 @@ internal data class ParsedItem( val url: String? = null, val summary: String? = null, val imageURL: String? = null, + val publishedAt: OffsetDateTime? ) diff --git a/basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt b/basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt index d82d0211..9742915d 100644 --- a/basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt +++ b/basil/src/main/java/com/jocmp/basil/articles/ArticleMapper.kt @@ -12,7 +12,7 @@ internal fun articleMapper( url: String?, summary: String?, imageURL: String?, - datePublished: Long? + publishedAt: Long? ): Article { return Article( id = id.toString(), diff --git a/basil/src/main/java/com/jocmp/basil/shared/TimeHelpers.kt b/basil/src/main/java/com/jocmp/basil/shared/TimeHelpers.kt new file mode 100644 index 00000000..5a397a3c --- /dev/null +++ b/basil/src/main/java/com/jocmp/basil/shared/TimeHelpers.kt @@ -0,0 +1,15 @@ +package com.jocmp.basil.shared + +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.format.DateTimeParseException + +fun parseISODate(value: String?): OffsetDateTime? { + value ?: return null + + return try { + OffsetDateTime.parse(value).withOffsetSameInstant(ZoneOffset.UTC) + } catch (_: DateTimeParseException) { + null + } +} diff --git a/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq b/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq index 03a9915f..c811e357 100644 --- a/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq +++ b/basil/src/main/sqldelight/com/jocmp/basil/db/articles.sq @@ -1,5 +1,3 @@ - - CREATE TABLE articles( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, external_id TEXT NOT NULL UNIQUE, @@ -9,7 +7,7 @@ CREATE TABLE articles( url TEXT, summary TEXT, image_url TEXT, - date_published INTEGER + published_at INTEGER ); countByFeed: @@ -21,6 +19,7 @@ allByFeed: SELECT * FROM articles WHERE feed_id = :feedID +ORDER BY published_at DESC LIMIT :limit OFFSET :offset; findBy: @@ -37,7 +36,8 @@ REPLACE INTO articles( content_html, url, summary, - image_url + image_url, + published_at ) VALUES ( ?, @@ -46,5 +46,6 @@ VALUES ( ?, ?, ?, +?, ? ); diff --git a/basil/src/test/java/com/jocmp/basil/AccountTest.kt b/basil/src/test/java/com/jocmp/basil/AccountTest.kt index d96e6b78..4173f75c 100644 --- a/basil/src/test/java/com/jocmp/basil/AccountTest.kt +++ b/basil/src/test/java/com/jocmp/basil/AccountTest.kt @@ -1,6 +1,5 @@ package com.jocmp.basil -import com.jocmp.basil.accounts.AccountDelegate import com.jocmp.basil.accounts.LocalAccountDelegate import com.jocmp.basil.db.Database import com.jocmp.feedfinder.FeedFinder diff --git a/basil/src/test/java/com/jocmp/basil/shared/TimeHelpersTest.kt b/basil/src/test/java/com/jocmp/basil/shared/TimeHelpersTest.kt new file mode 100644 index 00000000..e33ef8d5 --- /dev/null +++ b/basil/src/test/java/com/jocmp/basil/shared/TimeHelpersTest.kt @@ -0,0 +1,41 @@ +package com.jocmp.basil.shared + + +import org.junit.Test +import java.time.OffsetDateTime +import java.time.ZoneOffset +import kotlin.test.assertEquals + +class TimeHelpersTest { + @Test + fun `parseISODate parses an offset ISO timestamp to UTC`() { + val result = parseISODate("2023-12-25T09:00:00-05:00") + + val expected = OffsetDateTime.of( + 2023, + 12, + 25, + 14, + 0, + 0, + 0, + ZoneOffset.UTC + ) + + assertEquals(expected = expected, actual = result) + } + + @Test + fun `parseISODate discards non-ISO formatted strings`() { + val result = parseISODate("Mon, 25 Dec 2023 17:18:03 +0000") + + assertEquals(expected = null, actual = result) + } + + @Test + fun `parseISODate discards null values`() { + val result = parseISODate(null) + + assertEquals(expected = null, actual = result) + } +}