Display feed image in FeedBottomSheet when available

This commit is contained in:
Shinokuni 2024-11-17 19:50:36 +01:00
parent cbd5c1bc3d
commit db04cdddb7
19 changed files with 133 additions and 45 deletions

View File

@ -28,6 +28,7 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"title" -> name = nonNullText() "title" -> name = nonNullText()
"link" -> parseLink(this@allChildrenAutoIgnore, feed) "link" -> parseLink(this@allChildrenAutoIgnore, feed)
"subtitle" -> description = nullableText() "subtitle" -> description = nullableText()
"logo" -> imageUrl = nullableText()
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) "entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
else -> skipContents() else -> skipContents()
} }
@ -52,6 +53,6 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
} }
companion object { companion object {
val names = Names.of("title", "link", "subtitle", "entry") val names = Names.of("title", "link", "subtitle", "logo", "entry")
} }
} }

View File

@ -5,7 +5,9 @@ import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item import com.readrops.db.entities.Item
import com.squareup.moshi.* import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() { class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
@ -27,8 +29,9 @@ class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
0 -> name = reader.nextNonEmptyString() 0 -> name = reader.nextNonEmptyString()
1 -> siteUrl = reader.nextNullableString() 1 -> siteUrl = reader.nextNullableString()
2 -> url = reader.nextNullableString() 2 -> url = reader.nextNullableString()
3 -> description = reader.nextNullableString() 3 -> imageUrl = reader.nextNullableString()
4 -> items += itemAdapter.fromJson(reader) 4 -> description = reader.nextNullableString()
5 -> items += itemAdapter.fromJson(reader)
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -42,6 +45,6 @@ class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
companion object { companion object {
val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url", val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url",
"feed_url", "description", "items") "feed_url", "icon", "description", "items")
} }
} }

View File

@ -40,8 +40,10 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
} }
private fun parseChannel(konsumer: Konsumer, feed: Feed) = with(konsumer) { private fun parseChannel(konsumer: Konsumer, feed: Feed) = with(konsumer) {
feed.url = attributes.getValueOrNull("about", feed.url = attributes.getValueOrNull(
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#") localName = "about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
)
allChildrenAutoIgnore(names) { allChildrenAutoIgnore(names) {
with(feed) { with(feed) {
@ -49,12 +51,16 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"title" -> name = nonNullText() "title" -> name = nonNullText()
"link" -> siteUrl = nonNullText() "link" -> siteUrl = nonNullText()
"description" -> description = nullableText() "description" -> description = nullableText()
"image" -> imageUrl = attributes.getValueOrNull(
localName = "resource",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
)
} }
} }
} }
} }
companion object { companion object {
val names = Names.of("title", "link", "description") val names = Names.of("title", "link", "description", "image")
} }
} }

View File

@ -35,6 +35,7 @@ class RSS2FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
url = attributes.getValueOrNull("href") url = attributes.getValueOrNull("href")
} }
"item" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore) "item" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
"image" -> imageUrl = parseImage(this@allChildrenAutoIgnore)
else -> skipContents() else -> skipContents()
} }
} }
@ -49,7 +50,20 @@ class RSS2FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
} }
} }
private fun parseImage(konsumer: Konsumer): String? = with(konsumer) {
var url: String? = null
allChildrenAutoIgnore(Names.of("url")) {
when (tagName) {
"url" -> url = nullableText()
else -> skipContents()
}
}
url
}
companion object { companion object {
val names = Names.of("title", "description", "link", "item") val names = Names.of("title", "description", "link", "item", "image")
} }
} }

View File

@ -27,6 +27,7 @@ class ATOMAdapterTest {
assertEquals(url, "https://github.com/readrops/Readrops/commits/develop.atom") assertEquals(url, "https://github.com/readrops/Readrops/commits/develop.atom")
assertEquals(siteUrl, "https://github.com/readrops/Readrops/commits/develop") assertEquals(siteUrl, "https://github.com/readrops/Readrops/commits/develop")
assertEquals(description, "Here is a subtitle") assertEquals(description, "Here is a subtitle")
assertEquals(imageUrl, "https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png")
} }
with(items[0]) { with(items[0]) {

View File

@ -35,6 +35,7 @@ class JSONFeedAdapterTest {
assertEquals(url, "http://flyingmeat.com/blog/feed.json") assertEquals(url, "http://flyingmeat.com/blog/feed.json")
assertEquals(siteUrl, "http://flyingmeat.com/blog/") assertEquals(siteUrl, "http://flyingmeat.com/blog/")
assertEquals(description, "News from your friends at Flying Meat.") assertEquals(description, "News from your friends at Flying Meat.")
assertEquals(imageUrl, "https://secure.flyingmeat.com/favicon.ico")
} }
with(items[0]) { with(items[0]) {

View File

@ -28,6 +28,7 @@ class RSS1AdapterTest {
assertEquals(url, "https://slashdot.org/") assertEquals(url, "https://slashdot.org/")
assertEquals(siteUrl, "https://slashdot.org/") assertEquals(siteUrl, "https://slashdot.org/")
assertEquals(description, "News for nerds, stuff that matters") assertEquals(description, "News for nerds, stuff that matters")
assertEquals(imageUrl, "https://a.fsdn.com/sd/topics/topicslashdot.gif")
} }
with(items[0]) { with(items[0]) {

View File

@ -27,6 +27,7 @@ class RSS2AdapterTest {
assertEquals(url, "https://news.ycombinator.com/feed/") assertEquals(url, "https://news.ycombinator.com/feed/")
assertEquals(siteUrl, "https://news.ycombinator.com/") assertEquals(siteUrl, "https://news.ycombinator.com/")
assertEquals(description, "Links for the intellectually curious, ranked by readers.") assertEquals(description, "Links for the intellectually curious, ranked by readers.")
assertEquals(imageUrl, "https://news.ycombinator.com/y18.svg")
} }
with(items[0]) { with(items[0]) {

View File

@ -6,6 +6,7 @@
<title>Recent Commits to Readrops:develop</title> <title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated> <updated>2020-09-06T21:09:59Z</updated>
<subtitle>Here is a subtitle</subtitle> <subtitle>Here is a subtitle</subtitle>
<logo>https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png</logo>
<entry> <entry>
<id>tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac</id> <id>tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac"/> <link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac"/>

View File

@ -4,6 +4,7 @@
"home_page_url": "http://flyingmeat.com/blog/", "home_page_url": "http://flyingmeat.com/blog/",
"feed_url": "http://flyingmeat.com/blog/feed.json", "feed_url": "http://flyingmeat.com/blog/feed.json",
"description": "News from your friends at Flying Meat.", "description": "News from your friends at Flying Meat.",
"icon": "https://secure.flyingmeat.com/favicon.ico",
"author": { "author": {
"name": "Gus Mueller" "name": "Gus Mueller"
}, },

View File

@ -6,6 +6,11 @@
<atom:link href="https://news.ycombinator.com/feed/" rel="self" /> <atom:link href="https://news.ycombinator.com/feed/" rel="self" />
<link>https://news.ycombinator.com/</link> <link>https://news.ycombinator.com/</link>
<description>Links for the intellectually curious, ranked by readers.</description> <description>Links for the intellectually curious, ranked by readers.</description>
<image>
<title>Hacker News</title>
<url>https://news.ycombinator.com/y18.svg</url>
<link>https://news.ycombinator.com/</link>
</image>
<item> <item>
<title>Africa declared free of wild polio</title> <title>Africa declared free of wild polio</title>
<link>https://www.bbc.com/news/world-africa-53887947</link> <link>https://www.bbc.com/news/world-africa-53887947</link>

View File

@ -20,11 +20,16 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.readrops.app.R import com.readrops.app.R
import com.readrops.app.util.theme.LargeSpacer import com.readrops.app.util.theme.LargeSpacer
@ -46,53 +51,87 @@ fun FeedModalBottomSheet(
canDeleteFeed: Boolean canDeleteFeed: Boolean
) { ) {
ModalBottomSheet( ModalBottomSheet(
dragHandle = null,
onDismissRequest = { onDismissRequest() } onDismissRequest = { onDismissRequest() }
) { ) {
Column { Column {
Row( Box(
horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.largeSpacing
)
) { ) {
AsyncImage( if (feed.imageUrl != null) {
model = feed.iconUrl, AsyncImage(
contentDescription = feed.name!!, model = feed.imageUrl,
placeholder = painterResource(id = R.drawable.ic_rss_feed_grey), contentDescription = null,
error = painterResource(id = R.drawable.ic_rss_feed_grey), contentScale = ContentScale.Crop,
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing) modifier = Modifier
) .matchParentSize()
.drawWithContent {
drawContent()
drawRect(
color = Color.Black.copy(alpha = 0.65f)
)
}
.blur(2.5.dp)
)
}
MediumSpacer() Row(
horizontalArrangement = Arrangement.Center,
Column { verticalAlignment = Alignment.CenterVertically,
Text( modifier = Modifier.padding(
text = feed.name!!, top = MaterialTheme.spacing.largeSpacing,
style = MaterialTheme.typography.titleLarge, start = MaterialTheme.spacing.largeSpacing,
maxLines = 1, end = MaterialTheme.spacing.largeSpacing,
overflow = TextOverflow.Ellipsis bottom = MaterialTheme.spacing.mediumSpacing
)
) {
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name!!,
placeholder = painterResource(id = R.drawable.ic_rss_feed_grey),
error = painterResource(id = R.drawable.ic_rss_feed_grey),
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
) )
if (feed.description != null) { MediumSpacer()
VeryShortSpacer()
Column {
Text( Text(
text = feed.description!!, text = feed.name!!,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = if (feed.imageUrl != null) {
maxLines = 2, Color.White
} else {
MaterialTheme.colorScheme.onBackground
},
maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
if (feed.description != null) {
VeryShortSpacer()
Text(
text = feed.description!!,
style = MaterialTheme.typography.bodyMedium,
color = if (feed.imageUrl != null) {
Color.White
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
} }
} }
} }
MediumSpacer() if (feed.imageUrl == null) {
HorizontalDivider(
HorizontalDivider( modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing) )
) }
MediumSpacer() MediumSpacer()

View File

@ -44,6 +44,7 @@ class GetFoldersWithFeeds(
id = it.feedId, id = it.feedId,
name = it.feedName, name = it.feedName,
iconUrl = it.feedIcon, iconUrl = it.feedIcon,
imageUrl = it.feedImage,
url = it.feedUrl, url = it.feedUrl,
siteUrl = it.feedSiteUrl, siteUrl = it.feedSiteUrl,
description = it.feedDescription, description = it.feedDescription,

View File

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 5, "version": 5,
"identityHash": "0bac941f8b1b6003c35a6d0cdc1f2e13", "identityHash": "63de09bfed367e5705a0889d928f056d",
"entities": [ "entities": [
{ {
"tableName": "Feed", "tableName": "Feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `url` TEXT, `siteUrl` TEXT, `last_updated` TEXT, `color` INTEGER NOT NULL, `icon_url` TEXT, `etag` TEXT, `last_modified` TEXT, `folder_id` INTEGER, `remote_id` TEXT, `account_id` INTEGER NOT NULL, `notification_enabled` INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(`folder_id`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `description` TEXT, `url` TEXT, `image_url` TEXT, `siteUrl` TEXT, `last_updated` TEXT, `color` INTEGER NOT NULL, `icon_url` TEXT, `etag` TEXT, `last_modified` TEXT, `folder_id` INTEGER, `remote_id` TEXT, `account_id` INTEGER NOT NULL, `notification_enabled` INTEGER NOT NULL DEFAULT 1, FOREIGN KEY(`folder_id`) REFERENCES `Folder`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`account_id`) REFERENCES `Account`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -32,6 +32,12 @@
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
}, },
{
"fieldPath": "imageUrl",
"columnName": "image_url",
"affinity": "TEXT",
"notNull": false
},
{ {
"fieldPath": "siteUrl", "fieldPath": "siteUrl",
"columnName": "siteUrl", "columnName": "siteUrl",
@ -520,7 +526,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0bac941f8b1b6003c35a6d0cdc1f2e13')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '63de09bfed367e5705a0889d928f056d')"
] ]
} }
} }

View File

@ -121,5 +121,8 @@ object MigrationFrom4To5 : Migration(4, 5) {
db.execSQL("DROP TABLE IF EXISTS `Account`") db.execSQL("DROP TABLE IF EXISTS `Account`")
db.execSQL("ALTER TABLE `_new_Account` RENAME TO `Account`") db.execSQL("ALTER TABLE `_new_Account` RENAME TO `Account`")
// add image_url field
db.execSQL("""ALTER TABLE `Feed` ADD `image_url` TEXT DEFAULT NULL""")
} }
} }

View File

@ -28,6 +28,7 @@ data class Feed(
var name: String? = null, var name: String? = null,
var description: String? = null, var description: String? = null,
var url: String? = null, var url: String? = null,
@ColumnInfo("image_url") var imageUrl: String? = null,
var siteUrl: String? = null, var siteUrl: String? = null,
@ColumnInfo("last_updated") var lastUpdated: String? = null, @ColumnInfo("last_updated") var lastUpdated: String? = null,
@ColorInt var color: Int = 0, @ColorInt var color: Int = 0,

View File

@ -16,6 +16,7 @@ data class FolderWithFeed(
val feedId: Int = 0, val feedId: Int = 0,
val feedName: String? = null, val feedName: String? = null,
val feedIcon: String? = null, val feedIcon: String? = null,
val feedImage: String? = null,
val feedUrl: String? = null, val feedUrl: String? = null,
val feedDescription: String? = null, val feedDescription: String? = null,
val feedSiteUrl: String? = null, val feedSiteUrl: String? = null,

View File

@ -11,6 +11,7 @@ object FoldersAndFeedsQueryBuilder {
"Feed.name As feedName", "Feed.name As feedName",
"Feed.icon_url As feedIcon", "Feed.icon_url As feedIcon",
"Feed.url As feedUrl", "Feed.url As feedUrl",
"Feed.image_url as feedImage",
"Feed.siteUrl As feedSiteUrl", "Feed.siteUrl As feedSiteUrl",
"Feed.description as feedDescription", "Feed.description as feedDescription",
"Feed.remote_id as feedRemoteId", "Feed.remote_id as feedRemoteId",

View File

@ -49,6 +49,7 @@ lifecyle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-comp
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-http = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } coil-http = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" }
coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
@ -112,7 +113,7 @@ compose = ["compose-foundation", "compose-runtime", "compose-animation",
voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-koin", "voyager-transitions"] voyager = ["voyager-navigator", "voyager-screenmodel", "voyager-tab-navigator", "voyager-koin", "voyager-transitions"]
lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-viewmodel-savedstate", lifecycle = ["lifecycle-viewmodel-ktx", "lifecycle-viewmodel-compose", "lifecycle-viewmodel-savedstate",
"lifecyle-runtime-compose"] "lifecyle-runtime-compose"]
coil = ["coil-compose", "coil-http"] coil = ["coil-compose", "coil-http", "coil-svg"]
coroutines = ["coroutines-core", "coroutines-android"] coroutines = ["coroutines-core", "coroutines-android"]
room = ["room-runtime", "room-ktx", "room-paging"] room = ["room-runtime", "room-ktx", "room-paging"]
koin = ["koin-core", "koin-android", "koin-androidx-compose"] koin = ["koin-core", "koin-android", "koin-androidx-compose"]