Skip to content
Merged
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
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ jobs:
working-directory: shared
- run: bun install --frozen-lockfile
- run: bun lint
- run: bun test
- run: bun pack
- uses: actions/upload-artifact@v7
with:
Expand Down
197 changes: 164 additions & 33 deletions PLAN.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces)
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/kotlin/cc/hafa/reader/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ReaderTheme {
ReaderTheme(viewModel.readingFont) {
ReaderScreen(viewModel)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.os.Bundle
import android.util.Log
import cc.hafa.reader.core.HttpFeedFetcher
import cc.hafa.reader.core.ReaderModel
import cc.hafa.reader.core.ReaderPreferences
import cc.hafa.reader.core.sync.SyncEngine
import cc.hafa.reader.data.ReaderDatabase
import cc.hafa.reader.data.RoomReaderStore
Expand Down Expand Up @@ -51,11 +52,14 @@ class ReaderApplication : Application() {
// Set once a screen shows, so sync listens to the account; a process started only to check feeds just uploads.
private val onScreen = AtomicBoolean(false)

/** The reader's small choices on this device. */
val preferences: ReaderPreferences by lazy { SharedReaderPreferences(this) }

/** Everything the reader holds, for the whole process. */
val model: ReaderModel by lazy {
ReaderModel(
store = RoomReaderStore(ReaderDatabase.open(this).dao()),
preferences = SharedReaderPreferences(this),
preferences = preferences,
fetcher = HttpFeedFetcher(OkHttpClient()),
scope = scope,
onBackgroundWork = { RefreshWorker.finishInBackground(this) },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import cc.hafa.reader.core.Feed
import cc.hafa.reader.core.FeedModel
import cc.hafa.reader.core.FeedSchedule
import cc.hafa.reader.core.ReaderPreferences
import cc.hafa.reader.core.ReadingFont
import cc.hafa.reader.core.ReaderStore
import cc.hafa.reader.core.Sort
import cc.hafa.reader.core.ViewSettings
Expand Down Expand Up @@ -172,6 +173,10 @@ class RoomReaderStore(private val dao: ReaderDao) : ReaderStore {

private const val UNREAD_KEY = "unread-only"
private const val VIEW_KEY = "view"
private const val READING_FONT_KEY = "reading-font"
// stored values, the same as the web's
private const val LITERATA_VALUE = "literata"
private const val PUBLIC_SANS_VALUE = "public-sans"

/** The reader's small choices in shared preferences. */
class SharedReaderPreferences(context: Context) : ReaderPreferences {
Expand All @@ -184,4 +189,8 @@ class SharedReaderPreferences(context: Context) : ReaderPreferences {
override var lastView: String
get() = shared.getString(VIEW_KEY, null) ?: "all"
set(value) = shared.edit { putString(VIEW_KEY, value) }

override var readingFont: ReadingFont
get() = if (shared.getString(READING_FONT_KEY, null) == PUBLIC_SANS_VALUE) ReadingFont.PublicSans else ReadingFont.Literata
set(value) = shared.edit { putString(READING_FONT_KEY, if (value == ReadingFont.PublicSans) PUBLIC_SANS_VALUE else LITERATA_VALUE) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.google.firebase.firestore.Source
import kotlinx.coroutines.tasks.await
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean

// Errors the rules or the data give, which no retry fixes.
private val REFUSALS = setOf(
Expand Down Expand Up @@ -146,14 +147,16 @@ class FirestoreAccountStore(private val db: FirebaseFirestore, private val uid:
}

override fun listenEntries(sinceMs: Long, onChanges: (List<EntryChange>) -> Unit, onError: (Throwable) -> Unit): Stop {
// The first snapshot is passed on even when empty, so sync knows the listener has caught up.
val heard = AtomicBoolean(false)
val registration = entries.whereGreaterThan("syncedAt", timestamp(sinceMs)).addSnapshotListener(snapshotExecutor) { snapshot, error ->
if (error != null) {
onError(error)
} else if (snapshot != null) {
val changes = snapshot.documentChanges
.filter { change -> change.type != DocumentChange.Type.REMOVED && !change.document.metadata.hasPendingWrites() }
.map { change -> EntryChange(change.document.id, parseEntry(change.document.data), syncedAt(change.document)) }
if (changes.isNotEmpty()) {
if (changes.isNotEmpty() || heard.compareAndSet(false, true)) {
onChanges(changes)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Login
import androidx.compose.material.icons.automirrored.outlined.Logout
Expand Down Expand Up @@ -194,11 +191,7 @@ fun AccountScreen(viewModel: ReaderViewModel) {
bottomBar = { scrollBehavior -> BackBottomBar(stringResource(R.string.sync_heading), scrollBehavior, viewModel::back) },
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
Modifier.scrollingScreen(padding),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (report.status == SyncStatus.Waiting) {
Expand Down
31 changes: 15 additions & 16 deletions android/app/src/main/kotlin/cc/hafa/reader/ui/AddFeedScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,14 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Login
import androidx.compose.material.icons.filled.Menu
Expand Down Expand Up @@ -121,11 +118,7 @@ fun AddFeedScreen(snapshot: Snapshot, viewModel: ReaderViewModel) {
bottomBar = { scrollBehavior -> BackBottomBar(stringResource(R.string.add_feed), scrollBehavior, viewModel::back) },
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
Modifier.scrollingScreen(padding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
AddFeedForm(snapshot, viewModel, first = false)
Expand All @@ -151,11 +144,7 @@ fun FirstFeedScreen(snapshot: Snapshot, viewModel: ReaderViewModel, onMenu: () -
val arriving = report.email.isNotEmpty() && !report.feedsKnown &&
report.status in setOf(SyncStatus.Connecting, SyncStatus.Syncing, SyncStatus.Synced)
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(24.dp),
Modifier.scrollingScreen(padding, margin = 24.dp),
verticalArrangement = Arrangement.Center,
) {
if (arriving) {
Expand Down Expand Up @@ -186,6 +175,8 @@ private fun AddFeedForm(snapshot: Snapshot, viewModel: ReaderViewModel, first: B
val adding = snapshot.state.adding
// a topic search, as opposed to adding an address or a file
var searching by remember { mutableStateOf(false) }
// the group imported feeds go into; blank keeps the file's folders
var importGroup by remember { mutableStateOf("") }

// When an address could not be added and was searched for instead, finding nothing gives the address's problem.
fun search(text: String, problem: FeedProblem? = null) {
Expand Down Expand Up @@ -242,7 +233,7 @@ private fun AddFeedForm(snapshot: Snapshot, viewModel: ReaderViewModel, first: B
if (source == null) {
viewModel.notify(Notice.Text(R.string.import_failed))
} else {
viewModel.importOpml(source)
viewModel.importOpml(source, importGroup.trim().ifEmpty { null })
}
}
}
Expand All @@ -269,7 +260,7 @@ private fun AddFeedForm(snapshot: Snapshot, viewModel: ReaderViewModel, first: B
Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
if (first) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.empty_title), fontFamily = Fraunces, fontWeight = FontWeight.Bold, fontSize = 24.sp)
Text(stringResource(R.string.empty_title), fontFamily = ReaderTheme.readingFamily, fontWeight = FontWeight.Bold, fontSize = 24.sp)
Text(stringResource(R.string.empty_body), fontSize = 14.sp, lineHeight = 20.sp, color = colors.muted)
}
}
Expand Down Expand Up @@ -349,8 +340,16 @@ private fun AddFeedForm(snapshot: Snapshot, viewModel: ReaderViewModel, first: B
}
HorizontalDivider(color = colors.line)
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.import_title), fontFamily = Fraunces, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(stringResource(R.string.import_title), fontFamily = ReaderTheme.readingFamily, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Text(stringResource(R.string.import_body), fontSize = 14.sp, lineHeight = 20.sp, color = colors.muted)
GroupField(
importGroup,
snapshot.view.folders,
label = stringResource(R.string.import_group_label),
placeholder = stringResource(R.string.import_keep_folders),
enabled = !adding,
onEdit = { group -> importGroup = group },
)
val shape = RoundedCornerShape(12.dp)
Column(
Modifier
Expand Down
55 changes: 24 additions & 31 deletions android/app/src/main/kotlin/cc/hafa/reader/ui/ArticleScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
Expand Down Expand Up @@ -93,8 +91,8 @@ import androidx.compose.ui.unit.sp
import cc.hafa.reader.R
import cc.hafa.reader.core.Entry
import cc.hafa.reader.core.Selection
import cc.hafa.reader.core.articleTopics
import cc.hafa.reader.core.decodeHtmlEntities
import cc.hafa.reader.core.ViewSettings
import cc.hafa.reader.core.showsLeadImage
import cc.hafa.reader.core.topicOff
import coil3.SingletonImageLoader
Expand Down Expand Up @@ -306,21 +304,12 @@ private fun ArticlePage(
start = padding.calculateStartPadding(layoutDirection) + side,
end = padding.calculateEndPadding(layoutDirection) + side,
top = 24.dp,
bottom = padding.calculateBottomPadding() + 32.dp,
bottom = scrollEndPadding(),
),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item(key = "head") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val dateText = if (entry.dated) articleDate(entry.published) else ""
val feedTitle = state.feeds.firstOrNull { feed -> feed.id == entry.feedId }?.title ?: ""
val meta = listOf(feedTitle, entry.author, dateText).filter { part -> part.isNotEmpty() }.joinToString(" · ")
Text(
meta,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp, lineHeight = 16.sp,
color = colors.muted,
)
val title = remember(entry.title, entry.url, colors) {
if (entry.url.isEmpty()) {
AnnotatedString(entry.title)
Expand All @@ -335,12 +324,21 @@ private fun ArticlePage(
}
Text(
title,
fontFamily = Fraunces,
fontFamily = ReaderTheme.readingFamily,
fontWeight = FontWeight.Bold,
fontSize = 30.sp,
lineHeight = 36.sp,
style = LocalTextStyle.current.copy(lineBreak = LineBreak.Heading),
)
val dateText = if (entry.dated) articleDate(entry.published) else ""
val feedTitle = state.feeds.firstOrNull { feed -> feed.id == entry.feedId }?.title ?: ""
val meta = listOf(feedTitle, entry.author, dateText).filter { part -> part.isNotEmpty() }.joinToString(" · ")
Text(
meta,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp, lineHeight = 16.sp,
color = colors.muted,
)
}
}
if (showImage) {
Expand All @@ -360,13 +358,18 @@ private fun ArticlePage(
// The actions and topics wait for the body, so they do not show under the title and then jump down.
if (split != null && entry.categories.isNotEmpty()) {
item(key = "categories") {
val topics = remember(entry.categories, view.topicOrder) { articleTopics(entry.categories, view.topicOrder) }
// Starred has no topic filter, so there the topics are plain labels.
if (state.selection == Selection.Starred) {
Text(entry.categories.joinToString(" · "), fontSize = 12.sp, lineHeight = 16.sp, color = colors.muted)
ChipRows(topics) { topic -> TopicLabel(topic) }
} else {
// The chips read the view's settings, which change at once; only the list waits for a refresh.
CategoryChips(entry.categories, view.viewSettings) { topic ->
viewModel.model.toggleTopic(topic, fromArticle = true)
ChipRows(topics) { topic ->
TopicChip(
topic,
off = topicOff(view.viewSettings, topic),
onToggle = { viewModel.model.toggleTopic(topic, fromArticle = true) },
)
}
}
}
Expand All @@ -375,17 +378,6 @@ private fun ArticlePage(
}
}

/** The article's topics as filter chips; turning one off hides its articles from the view. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CategoryChips(categories: List<String>, settings: ViewSettings, onToggle: (String) -> Unit) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (category in categories) {
TopicChip(category, off = topicOff(settings, category), onToggle = { onToggle(category) })
}
}
}

/**
* The drag past the first or last page, which the pager leaves unused: the
* pages follow it, and on release one of at least [threshold] pixels slides
Expand Down Expand Up @@ -470,7 +462,7 @@ private fun BodyBlock(block: HtmlBlock) {
val links = TextLinkStyles(SpanStyle(color = colors.accent))
val text = remember(block.html, links) { AnnotatedString.fromHtml(block.html, linkStyles = links) }
val quoted = block.quoteDepth > 0 || block.kind == TextKind.Caption
val style = bodyStyle(block)
val style = bodyStyle(block, ReaderTheme.readingFamily)
Row(
Modifier
.fillMaxWidth()
Expand Down Expand Up @@ -530,8 +522,8 @@ private val HEADING_STYLES = listOf(
TextStyle(fontSize = 16.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold),
)

private fun bodyStyle(block: HtmlBlock.Text): TextStyle {
val base = TextStyle(fontFamily = PublicSans)
private fun bodyStyle(block: HtmlBlock.Text, family: FontFamily): TextStyle {
val base = TextStyle(fontFamily = family)
return when (block.kind) {
TextKind.Heading -> base.merge(HEADING_STYLES[(block.level - 1).coerceIn(0, HEADING_STYLES.size - 1)])
TextKind.Caption -> base.copy(fontSize = 14.sp, lineHeight = 20.sp, fontStyle = FontStyle.Italic)
Expand Down Expand Up @@ -561,6 +553,7 @@ private fun BodyTable(table: HtmlBlock.Table) {
.fillMaxHeight()
.border(0.5.dp, colors.line)
.padding(horizontal = 8.dp, vertical = 6.dp),
fontFamily = ReaderTheme.readingFamily,
fontSize = 14.sp,
lineHeight = 20.sp,
fontWeight = if (cell.header) FontWeight.SemiBold else FontWeight.Normal,
Expand Down
Loading
Loading