Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ class AppPreferences(context: Context) {
val shortenTitles: Preference<Boolean>
get() = preferenceStore.getBoolean("article_display_shorten_titles", true)

val showReadingTime: Preference<Boolean>
get() = preferenceStore.getBoolean("article_display_show_reading_time", false)

val fontScale: Preference<ArticleListFontScale>
get() = preferenceStore.getEnum(
"article_display_font_scale",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ fun rememberArticleOptions(appPreferences: AppPreferences = koinInject()): Artic
val fontScale by appPreferences.articleListOptions.fontScale.stateIn(scope).collectAsState()
val shortenTitles by appPreferences.articleListOptions.shortenTitles.stateIn(scope)
.collectAsState()
val showReadingTime by appPreferences.articleListOptions.showReadingTime.stateIn(scope)
.collectAsState()
val accentColors by appPreferences.accentColors.stateIn(scope).collectAsState()

return ArticleRowOptions(
Expand All @@ -225,6 +227,7 @@ fun rememberArticleOptions(appPreferences: AppPreferences = koinInject()): Artic
imagePreview = imagePreview,
fontScale = fontScale,
shortenTitles = shortenTitles,
showReadingTime = showReadingTime,
accentColors = accentColors,
)
}
27 changes: 22 additions & 5 deletions app/src/main/java/com/capyreader/app/ui/articles/ArticleRow.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import com.capyreader.app.ui.theme.LocalAppTheme
import com.jocmp.capy.Article
import com.jocmp.capy.EnclosureType
import com.jocmp.capy.MarkRead
import com.jocmp.capy.articles.readingTimeMinutes
import com.jocmp.capy.articles.relativeTime
import java.net.URL
import kotlinx.coroutines.launch
Expand All @@ -84,6 +85,7 @@ data class ArticleRowOptions(
val fontScale: ArticleListFontScale = ArticleListFontScale.MEDIUM,
val shortenTitles: Boolean = true,
val accentColors: Boolean = false,
val showReadingTime: Boolean = false,
val dim: Boolean = true,
)

Expand Down Expand Up @@ -181,12 +183,27 @@ fun ArticleRow(
.padding(end = 2.dp)
)
}
val time = relativeTime(
time = article.publishedAt,
currentTime = currentTime,
formats = LocalTimeFormats.current,
)
val readingTime = remember(article.id, options.showReadingTime) {
if (options.showReadingTime) {
article.readingTimeMinutes()
} else {
0
}
}
val overlineText = if (readingTime > 0) {
"$time ${
stringResource(R.string.article_reading_time, readingTime)
}"
} else {
time
}
Text(
text = relativeTime(
time = article.publishedAt,
currentTime = currentTime,
formats = LocalTimeFormats.current,
),
text = overlineText,
color = feedNameColor,
maxLines = 1,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ data class ArticleListOptions(
val showFeedName: Boolean,
val showSummary: Boolean,
val shortenTitles: Boolean,
val showReadingTime: Boolean,
val fontScale: ArticleListFontScale,
val updateFeedIcons: (show: Boolean) -> Unit,
val updateFeedName: (show: Boolean) -> Unit,
val updateImagePreview: (preview: ImagePreview) -> Unit,
val updateSummary: (show: Boolean) -> Unit,
val updateFontScale: (scale: ArticleListFontScale) -> Unit,
val updateShortenTitles: (show: Boolean) -> Unit,
val updateShowReadingTime: (show: Boolean) -> Unit,
)

@Composable
Expand Down Expand Up @@ -109,6 +111,11 @@ fun ArticleListSettings(
checked = options.shortenTitles,
title = stringResource(R.string.settings_article_list_shorten_titles)
)
TextSwitch(
onCheckedChange = options.updateShowReadingTime,
checked = options.showReadingTime,
title = stringResource(R.string.settings_article_list_reading_time)
)
}

PreferenceSelect(
Expand All @@ -133,6 +140,7 @@ private fun PreviewArticleRow(options: ArticleListOptions) {
imagePreview = options.imagePreview,
fontScale = options.fontScale,
shortenTitles = options.shortenTitles,
showReadingTime = options.showReadingTime,
dim = false,
)
val colors = ListItemDefaults.colors()
Expand Down Expand Up @@ -176,7 +184,11 @@ private fun PreviewArticleRow(options: ArticleListOptions) {
Spacer(Modifier.width(16.dp))
}
Text(
text = PREVIEW_TIME,
text = if (options.showReadingTime) {
"$PREVIEW_TIME $PREVIEW_READING_TIME"
} else {
PREVIEW_TIME
},
color = overlineColor,
maxLines = 1,
)
Expand Down Expand Up @@ -268,6 +280,7 @@ private fun Modifier.monochromeBorder(shape: Shape): Modifier {
private const val PREVIEW_TITLE = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua"
private const val PREVIEW_FEED_NAME = "Lorem Ipsum"
private const val PREVIEW_TIME = "3h"
private const val PREVIEW_READING_TIME = "5 min read"
private const val PREVIEW_SUMMARY = "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."

@Preview
Expand All @@ -281,12 +294,14 @@ private fun ArticleListSettingsPreview() {
fontScale = ArticleListFontScale.LARGE,
showFeedName = false,
shortenTitles = true,
showReadingTime = true,
updateImagePreview = {},
updateSummary = {},
updateFeedName = {},
updateFeedIcons = {},
updateFontScale = {},
updateShortenTitles = {},
updateShowReadingTime = {},
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ fun ArticleListSettingsPanel(
showFeedIcons = viewModel.showFeedIcons,
showFeedName = viewModel.showFeedName,
shortenTitles = viewModel.shortenTitles,
showReadingTime = viewModel.showReadingTime,
updateImagePreview = viewModel::updateImagePreview,
updateSummary = viewModel::updateSummary,
updateFeedName = viewModel::updateFeedName,
updateFeedIcons = viewModel::updateFeedIcons,
updateFontScale = viewModel::updateFontScale,
updateShortenTitles = viewModel::updateShortenTitles,
updateShowReadingTime = viewModel::updateShowReadingTime,
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class DisplaySettingsViewModel(

private val _shortenTitles = mutableStateOf(appPreferences.articleListOptions.shortenTitles.get())

private val _showReadingTime =
mutableStateOf(appPreferences.articleListOptions.showReadingTime.get())

var fontScale by mutableStateOf(appPreferences.articleListOptions.fontScale.get())
private set

Expand All @@ -57,6 +60,9 @@ class DisplaySettingsViewModel(
val shortenTitles: Boolean
get() = _shortenTitles.value

val showReadingTime: Boolean
get() = _showReadingTime.value

val pinArticleBars = appPreferences.readerOptions.pinToolbars

var imageVisibility by mutableStateOf(appPreferences.readerOptions.imageVisibility.get())
Expand Down Expand Up @@ -130,4 +136,10 @@ class DisplaySettingsViewModel(

_shortenTitles.value = shortenTitles
}

fun updateShowReadingTime(show: Boolean) {
appPreferences.articleListOptions.showReadingTime.set(show)

_showReadingTime.value = show
}
}
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@
<string name="settings_article_list_feed_icons">Feed Icons</string>
<string name="settings_article_list_summary">Summary</string>
<string name="settings_article_list_shorten_titles">Shorten titles</string>
<string name="settings_article_list_reading_time">Reading time</string>
<string name="article_reading_time">%1$d min read</string>
<string name="settings_section_version">Version</string>
<string name="settings_option_copy_version">Copy version</string>
<string name="settings_option_full_content_title">Sticky Full Content</string>
Expand Down
32 changes: 32 additions & 0 deletions capy/src/main/java/com/jocmp/capy/articles/ReadingTime.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.jocmp.capy.articles

import com.jocmp.capy.Article
import org.jsoup.Jsoup

private const val WORDS_PER_MINUTE = 225

/**
* Estimated reading time in minutes for the article, based on the
* article's content (or summary as a fallback). Always returns at
* least one minute when there is any content.
*/
fun Article.readingTimeMinutes(): Int {
val source = contentHTML.ifBlank { summary }

if (source.isBlank()) {
return 0
}

val text = Jsoup.parse(source).text()
val wordCount = text
.split(WORD_SEPARATORS)
.count { it.isNotBlank() }

if (wordCount == 0) {
return 0
}

return ((wordCount + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE).coerceAtLeast(1)
}

private val WORD_SEPARATORS = Regex("\\s+")
71 changes: 71 additions & 0 deletions capy/src/test/java/com/jocmp/capy/articles/ReadingTimeTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.jocmp.capy.articles

import com.jocmp.capy.Article
import org.junit.Test
import java.net.URL
import java.time.ZonedDateTime
import kotlin.test.assertEquals

class ReadingTimeTest {

@Test
fun `returns zero when article has no content`() {
val article = createArticle(contentHTML = "", summary = "")

assertEquals(0, article.readingTimeMinutes())
}

@Test
fun `rounds up to the nearest minute`() {
val words = List(50) { "word" }.joinToString(" ")
val article = createArticle(contentHTML = "<p>$words</p>")

assertEquals(1, article.readingTimeMinutes())
}

@Test
fun `computes reading time from word count using ~225 wpm`() {
val words = List(900) { "word" }.joinToString(" ")
val article = createArticle(contentHTML = "<p>$words</p>")

assertEquals(4, article.readingTimeMinutes())
}

@Test
fun `strips HTML tags before counting words`() {
val words = List(225) { "word" }.joinToString(" ")
val article = createArticle(
contentHTML = "<div><script>noisy();</script><p>$words</p></div>"
)

assertEquals(1, article.readingTimeMinutes())
}

@Test
fun `falls back to summary when contentHTML is blank`() {
val words = List(225) { "word" }.joinToString(" ")
val article = createArticle(contentHTML = "", summary = words)

assertEquals(1, article.readingTimeMinutes())
}

private fun createArticle(
contentHTML: String = "<p>Content</p>",
summary: String = "Summary",
): Article {
return Article(
id = "1",
feedID = "feed-1",
title = "Test",
author = null,
contentHTML = contentHTML,
url = URL("https://example.com/article"),
summary = summary,
imageURL = null,
updatedAt = ZonedDateTime.now(),
publishedAt = ZonedDateTime.now(),
read = false,
starred = false,
)
}
}
Loading