Merge pull request #2174 from omnivore-app/fix/android-loading

Improvements to Android library loading
This commit is contained in:
Jackson Harper 2023-05-09 16:24:54 +08:00 committed by GitHub
commit f8fc1306df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 833 additions and 848 deletions

View file

@ -17,8 +17,8 @@ android {
applicationId "app.omnivore.omnivore"
minSdk 26
targetSdk 33
versionCode 61
versionName "0.0.61"
versionCode 70
versionName "0.0.70"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View file

@ -11,6 +11,8 @@ import javax.inject.Inject
interface DatastoreRepository {
val hasAuthTokenFlow: Flow<Boolean>
val themeKeyFlow: Flow<String>
suspend fun clear()
suspend fun putString(key: String, value: String)
suspend fun putInt(key: String, value: Int)
@ -67,4 +69,10 @@ class OmnivoreDatastore @Inject constructor(
val token = preferences[key]
token != null
}
override val themeKeyFlow: Flow<String> = context
.dataStore.data.map { preferences ->
val key = stringPreferencesKey(DatastoreKeys.preferredTheme)
preferences[key] ?: "System"
}
}

View file

@ -1,126 +0,0 @@
package app.omnivore.omnivore.dataService
import androidx.lifecycle.LiveData
import androidx.lifecycle.MediatorLiveData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.ui.library.SavedItemFilter
import app.omnivore.omnivore.ui.library.SavedItemSortFilter
fun DataService.libraryLiveData(
primaryFilter: SavedItemFilter,
sortFilter: SavedItemSortFilter,
labels: List<SavedItemLabel>
): LiveData<List<SavedItemCardDataWithLabels>> {
val mediatorLiveData = MediatorLiveData<List<SavedItemCardDataWithLabels>>()
val queryParams = LibraryLiveDataQueryParams.make(primaryFilter)
val libraryLiveData = when (sortFilter) {
SavedItemSortFilter.NEWEST -> db.savedItemDao().getLibraryLiveData(
archiveFilter = queryParams.archiveFilter
)
SavedItemSortFilter.OLDEST -> db.savedItemDao().getLibraryLiveDataSortedByOldest(
archiveFilter = queryParams.archiveFilter
)
SavedItemSortFilter.RECENTLY_READ -> db.savedItemDao().getLibraryLiveDataSortedByRecentlyRead(
archiveFilter = queryParams.archiveFilter
)
SavedItemSortFilter.RECENTLY_PUBLISHED -> db.savedItemDao().getLibraryLiveDataSortedByRecentlyPublished(
archiveFilter = queryParams.archiveFilter
)
}
mediatorLiveData.addSource(libraryLiveData) { result ->
when (primaryFilter) {
SavedItemFilter.INBOX -> {
mediatorLiveData.value = result
}
SavedItemFilter.READ_LATER -> {
mediatorLiveData.value = result.filter { item ->
!item.labels.any { it.name.lowercase() == "newsletter" }
}
}
SavedItemFilter.NEWSLETTERS -> {
mediatorLiveData.value = result.filter { item ->
item.labels.any { it.name.lowercase() == "newsletter" }
}
}
SavedItemFilter.RECOMMENDED -> {
mediatorLiveData.value = result // TODO: "recommendations.@count > 0"
}
SavedItemFilter.ALL -> {
mediatorLiveData.value = result
}
SavedItemFilter.ARCHIVED -> {
mediatorLiveData.value = result
}
SavedItemFilter.HAS_HIGHLIGHTS -> {
mediatorLiveData.value = result // TODO: "highlights.@count > 0"
}
SavedItemFilter.FILES -> {
mediatorLiveData.value = result.filter { item ->
item.cardData.contentReader == "PDF"
}
}
}
if (labels.isNotEmpty()) {
mediatorLiveData.value = (mediatorLiveData.value ?: listOf()).filter {
it.labels.intersect(labels.toSet()).any()
}
}
}
return mediatorLiveData
}
private data class LibraryLiveDataQueryParams(
val archiveFilter: Int
) {
companion object {
fun make(savedItemFilter: SavedItemFilter): LibraryLiveDataQueryParams {
return when (savedItemFilter) {
SavedItemFilter.INBOX -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.READ_LATER -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.NEWSLETTERS -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.RECOMMENDED -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.ALL -> {
LibraryLiveDataQueryParams(
archiveFilter = 2, // Don't filter anything out (2 will not match anything)
)
}
SavedItemFilter.ARCHIVED -> {
LibraryLiveDataQueryParams(
archiveFilter = 0, // Filter out items not marked as archived
)
}
SavedItemFilter.HAS_HIGHLIGHTS -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
SavedItemFilter.FILES -> {
LibraryLiveDataQueryParams(
archiveFilter = 1, // Filter out items marked as archive
)
}
}
}
}
}

View file

@ -17,24 +17,7 @@ suspend fun DataService.librarySearch(cursor: String?, query: String): SearchRes
)
}
db.savedItemDao().insertAll(savedItems.map { it.savedItem })
val labels: MutableList<SavedItemLabel> = mutableListOf()
val crossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
// save labels
for (searchItem in searchResult.items) {
labels.addAll(searchItem.labels)
val newCrossRefs = searchItem.labels.map {
SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = searchItem.item.savedItemId)
}
crossRefs.addAll(newCrossRefs)
}
db.savedItemLabelDao().insertAll(labels)
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d("sync", "found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor")
@ -52,7 +35,7 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S
?: return SavedItemSyncResult.errorResult
val savedItems = syncResult.items.map {
SavedItem(
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
createdAt = it.createdAt as String,
@ -74,71 +57,40 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S
content = null,
wordsCount = it.wordsCount
)
}
db.savedItemDao().insertAll(savedItems)
val labels: MutableList<SavedItemLabel> = mutableListOf()
val crossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
// save labels
for (item in syncResult.items) {
val itemLabels = (item.labels ?: listOf()).map {
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
}
labels.addAll(itemLabels)
val newCrossRefs = itemLabels.map {
SavedItemAndSavedItemLabelCrossRef(
savedItemLabelId = it.savedItemLabelId,
savedItemId = item.id
} ?: listOf()
val highlights = it.highlights?.map { highlight ->
Highlight(
type = highlight.highlightFields.type.toString(),
highlightId = highlight.highlightFields.id,
annotation = highlight.highlightFields.annotation,
createdByMe = highlight.highlightFields.createdByMe,
markedForDeletion = false,
patch = highlight.highlightFields.patch,
prefix = highlight.highlightFields.prefix,
quote = highlight.highlightFields.quote,
serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue,
shortId = highlight.highlightFields.shortId,
suffix = highlight.highlightFields.suffix,
createdAt = null,
updatedAt = highlight.highlightFields.updatedAt as String?,
)
}
crossRefs.addAll(newCrossRefs)
}
db.savedItemLabelDao().insertAll(labels)
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
// Persist Highlights
db.highlightDao().insertAll(syncResult.items.flatMap {
it.highlights ?: listOf()
}.map {
Highlight(
type = it.highlightFields.type.toString(),
highlightId = it.highlightFields.id,
annotation = it.highlightFields.annotation,
createdByMe = it.highlightFields.createdByMe,
markedForDeletion = false,
patch = it.highlightFields.patch,
prefix = it.highlightFields.prefix,
quote = it.highlightFields.quote,
serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue,
shortId = it.highlightFields.shortId,
suffix = it.highlightFields.suffix,
createdAt = null,
updatedAt = it.highlightFields.updatedAt as String?,
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem,
labels = labels,
highlights = highlights
)
})
val highlightCrossRefs = syncResult.items.flatMap {
val savedItem = it
(savedItem.highlights ?: listOf()).map {
Pair(it, savedItem.id)
}
}.map {
SavedItemAndHighlightCrossRef(highlightId = it.first.highlightFields.id, savedItemId = it.second)
}
db.savedItemAndHighlightCrossRefDao().insertAll(highlightCrossRefs)
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
@ -159,28 +111,16 @@ fun DataService.isSavedItemContentStoredInDB(slug: String): Boolean {
suspend fun DataService.fetchSavedItemContent(slug: String) {
val syncResult = networker.savedItem(slug)
val isSuccess = syncResult.item != null
val savedItem = syncResult.item ?: return
db.savedItemDao().insert(savedItem)
// Persist Labels
db.savedItemLabelDao().insertAll(syncResult.labels)
val labelCrossRefs = syncResult.labels.map {
SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = savedItem.savedItemId)
val savedItem = syncResult.item
savedItem?.let {
val item = SavedItemWithLabelsAndHighlights(
savedItem = savedItem,
labels = syncResult.labels,
highlights = syncResult.highlights
)
db.savedItemWithLabelsAndHighlightsDao().insertAll(listOf(item))
}
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(labelCrossRefs)
// Persist Highlights
db.highlightDao().insertAll(syncResult.highlights)
val highlightCrossRefs = syncResult.highlights.map {
SavedItemAndHighlightCrossRef(highlightId = it.highlightId, savedItemId = savedItem.savedItemId)
}
db.savedItemAndHighlightCrossRefDao().insertAll(highlightCrossRefs)
}

View file

@ -1,12 +1,7 @@
package app.omnivore.omnivore.networking
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.TypeaheadCardData
import com.apollographql.apollo3.api.Optional
data class SearchQueryResponse(
val cursor: String?,

View file

@ -13,13 +13,14 @@ import app.omnivore.omnivore.persistence.entities.*
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class
],
version = 6
version = 7
)
abstract class AppDatabase : RoomDatabase() {
abstract fun viewerDao(): ViewerDao
abstract fun savedItemDao(): SavedItemDao
abstract fun highlightDao(): HighlightDao
abstract fun savedItemLabelDao(): SavedItemLabelDao
abstract fun savedItemWithLabelsAndHighlightsDao(): SavedItemWithLabelsAndHighlightsDao
abstract fun savedItemAndSavedItemLabelCrossRefDao(): SavedItemAndSavedItemLabelCrossRefDao
abstract fun savedItemAndHighlightCrossRefDao(): SavedItemAndHighlightCrossRefDao
}

View file

@ -34,7 +34,6 @@ data class Highlight(
entity = Highlight::class,
parentColumns = arrayOf("highlightId"),
childColumns = arrayOf("highlightId"),
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = SavedItem::class,

View file

@ -1,13 +1,10 @@
package app.omnivore.omnivore.persistence.entities
import android.util.Log
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.room.*
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.ui.library.SavedItemSortFilter
import java.util.*
@Entity
@ -69,30 +66,6 @@ data class SavedItem(
}
}
data class SavedItemCardData(
val savedItemId: String,
val slug: String,
val publisherURLString: String?,
val title: String,
val author: String?,
val imageURLString: String?,
val isArchived: Boolean,
val pageURLString: String,
val contentReader: String?,
val savedAt: String,
val readingProgress: Double,
val wordsCount: Int?
) {
fun publisherDisplayName(): String? {
return publisherURLString?.toUri()?.host
}
fun isPDF(): Boolean {
val hasPDFSuffix = pageURLString.endsWith("pdf")
return contentReader == "PDF" || hasPDFSuffix
}
}
data class TypeaheadCardData(
val savedItemId: String,
val slug: String,
@ -100,6 +73,58 @@ data class TypeaheadCardData(
val isArchived: Boolean,
)
@Dao
abstract class SavedItemWithLabelsAndHighlightsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertSavedItems(items: List<SavedItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabelCrossRefs(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabels(items: List<SavedItemLabel>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlights(items: List<Highlight>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlightCrossRefs(items: List<SavedItemAndHighlightCrossRef>)
@Transaction
open fun insertAll(savedItems: List<SavedItemWithLabelsAndHighlights>) {
insertSavedItems(savedItems.map { it.savedItem })
val labels: MutableList<SavedItemLabel> = mutableListOf()
val highlights: MutableList<Highlight> = mutableListOf()
val labelCrossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
val highlightCrossRefs: MutableList<SavedItemAndHighlightCrossRef> = mutableListOf()
for (searchItem in savedItems) {
labels.addAll(searchItem.labels)
highlights.addAll(searchItem.highlights)
val newLabelCrossRefs = searchItem.labels.map {
SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = searchItem.savedItem.savedItemId)
}
val newHighlightCrossRefs = searchItem.highlights.map {
SavedItemAndHighlightCrossRef(highlightId = it.highlightId, savedItemId = searchItem.savedItem.savedItemId)
}
labelCrossRefs.addAll(newLabelCrossRefs)
highlightCrossRefs.addAll(newHighlightCrossRefs)
}
insertLabels(labels)
insertLabelCrossRefs(labelCrossRefs)
insertHighlights(highlights)
insertHighlightCrossRefs(highlightCrossRefs)
}
}
@Dao
interface SavedItemDao {
@Query("SELECT * FROM savedItem")
@ -114,54 +139,12 @@ interface SavedItemDao {
@Query("SELECT * FROM savedItem WHERE slug = :slug")
fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(item: SavedItem)
@Query("DELETE FROM savedItem WHERE savedItemId = :itemID")
fun deleteById(itemID: String)
@Update
fun update(savedItem: SavedItem)
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY savedAt DESC"
)
fun getLibraryLiveData(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY savedAt ASC"
)
fun getLibraryLiveDataSortedByOldest(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY readAt DESC, savedAt DESC"
)
fun getLibraryLiveDataSortedByRecentlyRead(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.columns} " +
"FROM SavedItem " +
"WHERE serverSyncStatus != 2 AND isArchived != :archiveFilter " +
"ORDER BY publishDate DESC"
)
fun getLibraryLiveDataSortedByRecentlyPublished(archiveFilter: Int): LiveData<List<SavedItemCardDataWithLabels>>
@Transaction
@Query(
"SELECT ${SavedItemQueryConstants.libraryColumns} " +
@ -173,8 +156,6 @@ interface SavedItemDao {
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.savedItemId = :savedItemId " +
"AND SavedItem.serverSyncStatus != 2 " +
"AND Highlight.serverSyncStatus != 2 " +
"GROUP BY SavedItem.savedItemId "
)
@ -208,7 +189,7 @@ interface SavedItemDao {
fun _filteredLibraryData(allowedArchiveStates: List<Int>, sortKey: String, hasRequiredLabels: Int, hasExcludedLabels: Int, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>>
fun filteredLibraryData(allowedArchiveStates: List<Int>, sortKey: String, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>> {
return _filteredLibraryData(
val result = _filteredLibraryData(
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
hasRequiredLabels = requiredLabels.size,
@ -217,6 +198,7 @@ interface SavedItemDao {
excludedLabels = excludedLabels,
allowedContentReaders = allowedContentReaders
)
return result
}
}

View file

@ -44,25 +44,6 @@ data class SavedItemAndSavedItemLabelCrossRef(
val savedItemId: String
)
data class SavedItemWithLabels(
@Embedded val savedItem: SavedItem,
@Relation(
parentColumn = "savedItemId",
entityColumn = "savedItemLabelId",
associateBy = Junction(SavedItemAndSavedItemLabelCrossRef::class)
)
val labels: List<SavedItemLabel>
)
data class SavedItemCardDataWithLabels(
@Embedded val cardData: SavedItemCardData,
@Relation(
parentColumn = "savedItemId",
entityColumn = "savedItemLabelId",
associateBy = Junction(SavedItemAndSavedItemLabelCrossRef::class)
)
val labels: List<SavedItemLabel>
)
@Dao
interface SavedItemAndSavedItemLabelCrossRefDao {

View file

@ -42,7 +42,6 @@ fun WebReaderLabelsSelectionSheet(viewModel: WebReaderViewModel) {
val modalBottomSheetState = rememberModalBottomSheetState(
ModalBottomSheetValue.HalfExpanded,
confirmStateChange = { it != ModalBottomSheetValue.Hidden }
)
if (isActive) {

View file

@ -3,13 +3,11 @@ package app.omnivore.omnivore.ui.library
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -19,14 +17,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.lifecycle.MutableLiveData
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -33,8 +33,6 @@ import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.components.LabelsSelectionSheet
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard

View file

@ -76,6 +76,8 @@ class LibraryViewModel @Inject constructor(
}
fun refresh() {
cursor = null
librarySearchCursor = null
isRefreshing = true
load(true)
}
@ -164,8 +166,9 @@ class LibraryViewModel @Inject constructor(
}
}
fun handleFilterChanges() {
librarySearchCursor = null
if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
val applied = appliedFilterLiveData.value
val sortKey = when (appliedSortFilterLiveData.value) {
SavedItemSortFilter.NEWEST -> "newest"
SavedItemSortFilter.OLDEST -> "oldest"

View file

@ -1,7 +1,6 @@
package app.omnivore.omnivore.ui.library
import androidx.lifecycle.MutableLiveData
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
interface SavedItemViewModel {

View file

@ -22,8 +22,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity
import app.omnivore.omnivore.persistence.entities.TypeaheadCardData

View file

@ -3,12 +3,7 @@ package app.omnivore.omnivore.ui.notebook
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
@ -37,126 +32,114 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.MainActivity
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.library.*
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import dagger.hilt.android.AndroidEntryPoint
import dev.jeziellago.compose.markdowntext.MarkdownText
import kotlinx.coroutines.launch
import app.omnivore.omnivore.persistence.entities.Highlight
@AndroidEntryPoint
class NotebookActivity: ComponentActivity() {
val viewModel: NotebookViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val savedItemId = intent.getStringExtra("SAVED_ITEM_ID")
fun notebookMD(notes: List<Highlight>, highlights: List<Highlight>): String {
var result = ""
setContent {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
if (notes.isNotEmpty()) {
result += "## Notes\n"
notes.forEach {
result += it.annotation + "\n"
}
result += "\n"
}
DisposableEffect(systemUiController, useDarkIcons) {
systemUiController.setSystemBarsColor(
color = Color.Black,
darkIcons = false
)
onDispose {}
}
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
// .background(color = Color.Black)
) {
savedItemId?.let {
NotebookView(
savedItemId = savedItemId,
viewModel = viewModel
)
}
}
}
if (highlights.isNotEmpty()) {
result += "## Highlights\n"
highlights.forEach {
result += "> ${it.quote}\n"
if ((it.annotation?: "").isNotEmpty()) {
result += it.annotation + "\n"
}
}
// // animate the view up when keyboard appears
// WindowCompat.setDecorFitsSystemWindows(window, false)
// val rootView = findViewById<View>(android.R.id.content).rootView
// ViewCompat.setOnApplyWindowInsetsListener(rootView) { _, insets ->
// val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
// rootView.setPadding(0, 0, 0, imeHeight)
// insets
// }
// }
private fun startMainActivity() {
val intent = Intent(this, MainActivity::class.java)
this.startActivity(intent)
result += "\n"
}
return result
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun NotebookView(savedItemId: String, viewModel: NotebookViewModel) {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
var isMenuOpen by remember {
mutableStateOf(false)
}
val savedItem = viewModel.getLibraryItemById(savedItemId).observeAsState()
val scrollState = rememberScrollState()
val modalBottomSheetState = rememberModalBottomSheetState(
ModalBottomSheetValue.Hidden,
)
val coroutineScope = rememberCoroutineScope()
val snackBarHostState = remember { SnackbarHostState() }
val clipboard: ClipboardManager? =
LocalContext.current.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
val notes = savedItem.value?.highlights?.filter { it.type == "NOTE" } ?: listOf()
val highlights = savedItem.value?.highlights?.filter { it.type == "HIGHLIGHT" } ?: listOf()
ModalBottomSheetLayout(
modifier = Modifier.statusBarsPadding(),
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
EditNoteModal()
// EditNoteModal()
Spacer(modifier = Modifier.weight(1.0F))
}
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notebook") },
modifier = Modifier.statusBarsPadding(),
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher?.onBackPressed()
}) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back"
)
actions = {
Box {
IconButton(onClick = {
isMenuOpen = true
}) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
if (isMenuOpen) {
DropdownMenu(
expanded = isMenuOpen,
onDismissRequest = { isMenuOpen = false }
) {
DropdownMenuItem(
text = { Text("Copy") },
onClick = {
val clip = ClipData.newPlainText("notebook", notebookMD(notes, highlights))
clipboard?.let {
it
clipboard?.setPrimaryClip(clip)
} ?: run {
coroutineScope.launch {
snackBarHostState
.showSnackbar("Notebook copied")
}
}
isMenuOpen = false
}
)
}
}
}
},
// actions = {
// IconButton(onClick = {
//
// }) {
// Icon(
// imageVector = Icons.Default.MoreVert,
// contentDescription = null
// )
// }
// }
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(scrollState)
.fillMaxSize()
) {
@ -166,7 +149,6 @@ fun NotebookView(savedItemId: String, viewModel: NotebookViewModel) {
}
HighlightsList(it)
}
Spacer(Modifier.weight(100f))
}
}
}

View file

@ -1,5 +1,6 @@
package app.omnivore.omnivore.ui.reader
import android.content.ClipData
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
@ -8,6 +9,10 @@ import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -22,7 +27,11 @@ import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
import app.omnivore.omnivore.ui.notebook.ArticleNotes
import app.omnivore.omnivore.ui.notebook.HighlightsList
import app.omnivore.omnivore.ui.notebook.notebookMD
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import kotlinx.coroutines.launch
class AnnotationEditFragment : DialogFragment() {
private var onSave: (String) -> Unit = {}
@ -54,7 +63,7 @@ class AnnotationEditFragment : DialogFragment() {
initialAnnotation,
onSave,
onCancel,
dismissAction = { dismiss() }
// dismissAction = { dismiss() }
)
}
}
@ -73,62 +82,96 @@ fun AnnotationEditView(
initialAnnotation: String,
onSave: (String) -> Unit,
onCancel: () -> Unit,
dismissAction: () -> Unit = {}
) {
val annotation = remember { mutableStateOf(initialAnnotation) }
val focusRequester = FocusRequester()
Column(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.background)
.padding(8.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row {
TextButton(
onClick = {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Notebook") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
navigationIcon = {
IconButton(onClick = {
onCancel()
dismissAction()
}) {
Icon(
imageVector = Icons.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back",
)
}
) {
Text("Cancel")
}
Spacer(modifier = Modifier.weight(1.0F))
Text(text = "Note")
Spacer(modifier = Modifier.weight(1.0F))
TextButton(
onClick = {
onSave(annotation.value)
dismissAction()
},
actions = {
TextButton(
onClick = {
onSave(annotation.value)
}
) {
Text("Save")
}
) {
Text("Save")
}
}
Spacer(modifier = Modifier.height(8.dp))
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
TextField(
value = annotation.value,
onValueChange = { annotation.value = it },
modifier = Modifier
.width(IntrinsicSize.Max)
.height(IntrinsicSize.Max)
.weight(1.0F)
.focusRequester(focusRequester)
.fillMaxSize()
)
}
}
}
//
// Column(
// modifier = Modifier.padding(16.dp),
// horizontalAlignment = Alignment.CenterHorizontally,
// ) {
// Row {
// TextButton(
// onClick = {
// onCancel()
// dismissAction()
// }
// ) {
// Text("Cancel")
// }
//
// Spacer(modifier = Modifier.weight(1.0F))
//
// Text(text = "Note")
//
// Spacer(modifier = Modifier.weight(1.0F))
//
// TextButton(
// onClick = {
// onSave(annotation.value)
// dismissAction()
// }
// ) {
// Text("Save")
// }
// }
//
// Spacer(modifier = Modifier.height(8.dp))
//
//
//
// Spacer(modifier = Modifier.height(16.dp))
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
//
// LaunchedEffect(Unit) {
// focusRequester.requestFocus()
// }
// Row {
// Spacer(modifier = Modifier.weight(0.1F))
@ -142,8 +185,7 @@ fun AnnotationEditView(
// )
// Spacer(modifier = Modifier.weight(0.1F))
// }
}
Spacer(modifier = Modifier.height(16.dp))
}
}
// }
//
// // }
//}

View file

@ -0,0 +1,288 @@
package app.omnivore.omnivore.ui.reader
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
@Composable
fun ReaderPreferencesView(webReaderViewModel: WebReaderViewModel) {
val isDark = isSystemInDarkTheme()
val currentWebPreferences = webReaderViewModel.storedWebPreferences(isDark)
val isFontListExpanded = remember { mutableStateOf(false) }
val highContrastTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersHighContrastText) }
val justifyTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersJustifyText) }
val selectedWebFontName = remember { mutableStateOf(currentWebPreferences.fontFamily.displayText) }
var fontSizeSliderValue by remember { mutableStateOf(currentWebPreferences.textFontSize.toFloat()) }
var marginSliderValue by remember { mutableStateOf(currentWebPreferences.maxWidthPercentage.toFloat()) }
var lineSpacingSliderValue by remember { mutableStateOf(currentWebPreferences.lineHeight.toFloat()) }
val themeState = remember { mutableStateOf(currentWebPreferences.storedThemePreference) }
val themeListState = rememberLazyListState()
OmnivoreTheme() {
Column(
modifier = Modifier
.padding(horizontal = 15.dp)
.padding(vertical = 35.dp)
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Font", style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Spacer(modifier = Modifier.weight(1.0F))
Box {
OutlinedButton(
shape = RoundedCornerShape(4.dp),
onClick = { isFontListExpanded.value = true },
colors = ButtonDefaults.buttonColors(
contentColor = Color(red = 137, green = 137, blue = 137),
// containerColor = Color.Transparent,
),
) {
Text(selectedWebFontName.value)
}
if (isFontListExpanded.value) {
DropdownMenu(
expanded = isFontListExpanded.value,
onDismissRequest = { isFontListExpanded.value = false },
) {
WebFont.values().forEach {
DropdownMenuItem(
text = {
Text(it.displayText, style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
},
onClick = {
webReaderViewModel.applyWebFont(it)
selectedWebFontName.value = it.displayText
isFontListExpanded.value = false
},
)
}
}
}
}
}
Text("Font Size:", style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Slider(
value = fontSizeSliderValue,
onValueChange = {
fontSizeSliderValue = it
webReaderViewModel.setFontSize(it.toInt())
},
steps = 10,
valueRange = 8f..28f,
)
Text("Margin", style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Slider(
value = marginSliderValue,
onValueChange = {
marginSliderValue = it
webReaderViewModel.setMaxWidthPercentage(it.toInt())
},
steps = 4,
valueRange = 60f..100f,
)
Text("Line Spacing", style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Slider(
value = lineSpacingSliderValue,
onValueChange = {
lineSpacingSliderValue = it
webReaderViewModel.setLineHeight(it.toInt())
},
steps = 8,
valueRange = 100f..300f,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 4.dp)
) {
Text("Theme:", style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Spacer(modifier = Modifier.weight(1.0F))
Text("Auto", style = TextStyle(
fontSize = 10.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Checkbox(
checked = themeState.value == "System",
onCheckedChange = {
if (it) {
themeState.value = "System"
webReaderViewModel.updateStoredThemePreference("System", isDark)
} else {
val newThemeKey = if (isDark) "Black" else "Light"
themeState.value = newThemeKey
webReaderViewModel.updateStoredThemePreference(newThemeKey, isDark)
}
})
}
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
for(theme in Themes.values()) {
if (theme.themeKey != "System") {
val isSelected = theme.themeKey == themeState.value
Button(
onClick = {
themeState.value = theme.themeKey
webReaderViewModel.updateStoredThemePreference(theme.themeKey, isDark)
},
shape = CircleShape,
border = BorderStroke(3.dp, if (isSelected) colorResource(R.color.cta_yellow) else Color.Transparent),
modifier = Modifier.size(35.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(theme.backgroundColor)
)
) {
}
Spacer(modifier = Modifier.weight(0.1F))
}
}
Spacer(modifier = Modifier.weight(2.0F))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("High Contrast Text",
style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137)
))
Spacer(modifier = Modifier.weight(1.0F))
Switch(
checked = highContrastTextSwitchState.value,
onCheckedChange = {
highContrastTextSwitchState.value = it
webReaderViewModel.updateHighContrastTextPreference(it)
}
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Justify Text",
style = TextStyle(
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
color = Color(red = 137, green = 137, blue = 137))
)
Spacer(modifier = Modifier.weight(1.0F))
Switch(
checked = justifyTextSwitchState.value,
onCheckedChange = {
justifyTextSwitchState.value = it
webReaderViewModel.updateJustifyText(it)
}
)
}
}
}
}
@Composable
fun Stepper(label: String, onIncrease: () -> Unit, onDecrease: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
modifier = Modifier
.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.weight(1.0F))
IconButton(onClick = { onDecrease() }) {
Icon(
painter = painterResource(id = R.drawable.minus),
contentDescription = null
)
}
Divider(
color = Color.Black,
modifier = Modifier
.height(20.dp)
.width(1.dp)
)
IconButton(onClick = { onIncrease() }) {
Icon(
painter = painterResource(id = R.drawable.plus),
contentDescription = null
)
}
}
}
data class WebPreferences(
val textFontSize: Int,
val lineHeight: Int,
val maxWidthPercentage: Int,
val themeKey: String,
val storedThemePreference: String,
val fontFamily: WebFont,
val prefersHighContrastText: Boolean,
val prefersJustifyText: Boolean
)

View file

@ -1,220 +0,0 @@
package app.omnivore.omnivore.ui.reader
import android.util.Log
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowRight
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.components.SegmentedControl
@Composable
fun WebPreferencesDialog(onDismiss: () -> Unit, webReaderViewModel: WebReaderViewModel) {
Dialog(onDismissRequest = { onDismiss() }) {
Surface(
shape = RoundedCornerShape(16.dp),
color = Color.White,
modifier = Modifier
.height(350.dp)
) {
WebPreferencesView(webReaderViewModel)
}
}
}
@Composable
fun WebPreferencesView(webReaderViewModel: WebReaderViewModel) {
val isDark = isSystemInDarkTheme()
val currentWebPreferences = webReaderViewModel.storedWebPreferences(isDark)
val isFontListExpanded = remember { mutableStateOf(false) }
val highContrastTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersHighContrastText) }
val justifyTextSwitchState = remember { mutableStateOf(currentWebPreferences.prefersJustifyText) }
val selectedWebFontRawValue = remember { mutableStateOf(currentWebPreferences.fontFamily.rawValue) }
Column(
modifier = Modifier
.padding(top = 6.dp, start = 6.dp, end = 6.dp, bottom = 6.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp),
horizontalArrangement = Arrangement.Center
) {
Text("Web Preferences")
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
Stepper(
label = "Font Size:",
onIncrease = { webReaderViewModel.updateFontSize(isIncrease = true) },
onDecrease = { webReaderViewModel.updateFontSize(isIncrease = false) }
)
Stepper(
label = "Margin:",
onIncrease = { webReaderViewModel.updateMaxWidthPercentage(isIncrease = false) },
onDecrease = { webReaderViewModel.updateMaxWidthPercentage(isIncrease = true) }
)
Stepper(
label = "Line Spacing:",
onIncrease = { webReaderViewModel.updateLineSpacing(isIncrease = true) },
onDecrease = { webReaderViewModel.updateLineSpacing(isIncrease = false) }
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("High Contrast Text")
Spacer(modifier = Modifier.weight(1.0F))
Switch(
checked = highContrastTextSwitchState.value,
onCheckedChange = {
highContrastTextSwitchState.value = it
webReaderViewModel.updateHighContrastTextPreference(it)
}
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Justify Text")
Spacer(modifier = Modifier.weight(1.0F))
Switch(
checked = justifyTextSwitchState.value,
onCheckedChange = {
justifyTextSwitchState.value = it
webReaderViewModel.updateJustifyText(it)
}
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 4.dp)
) {
Text("Theme:")
Spacer(modifier = Modifier.weight(1.0F))
SegmentedControl(
items = webReaderViewModel.systemThemeKeys,
initialSelectedItemIndex = webReaderViewModel.systemThemeKeys.indexOf(currentWebPreferences.storedThemePreference)
) {
webReaderViewModel.updateStoredThemePreference(it, isDark)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = { isFontListExpanded.value = !isFontListExpanded.value })
) {
Text("Font Family")
Spacer(modifier = Modifier.weight(1.0F))
Icon(
imageVector =
if (isFontListExpanded.value)
Icons.Filled.KeyboardArrowDown
else
Icons.Filled.KeyboardArrowRight,
contentDescription = null
)
}
if (isFontListExpanded.value) {
WebFont.values().forEach {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(onClick = {
webReaderViewModel.applyWebFont(it)
selectedWebFontRawValue.value = it.rawValue
})
) {
Text(
it.displayText,
modifier = Modifier
.padding(top = 6.dp, start = 6.dp, end = 6.dp, bottom = 6.dp)
)
Spacer(modifier = Modifier.weight(1.0F))
if (it.rawValue == selectedWebFontRawValue.value) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null
)
}
}
}
}
}
}
}
@Composable
fun Stepper(label: String, onIncrease: () -> Unit, onDecrease: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = label,
modifier = Modifier
.padding(bottom = 6.dp)
)
Spacer(modifier = Modifier.weight(1.0F))
IconButton(onClick = { onDecrease() }) {
Icon(
painter = painterResource(id = R.drawable.minus),
contentDescription = null
)
}
Divider(
color = Color.Black,
modifier = Modifier
.height(20.dp)
.width(1.dp)
)
IconButton(onClick = { onIncrease() }) {
Icon(
painter = painterResource(id = R.drawable.plus),
contentDescription = null
)
}
}
}
data class WebPreferences(
val textFontSize: Int,
val lineHeight: Int,
val maxWidthPercentage: Int,
val themeKey: String,
val storedThemePreference: String,
val fontFamily: WebFont,
val prefersHighContrastText: Boolean,
val prefersJustifyText: Boolean
)

View file

@ -30,7 +30,6 @@ import java.util.*
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebReader(
preferences: WebPreferences,
styledContent: String,
webReaderViewModel: WebReaderViewModel
) {
@ -115,14 +114,6 @@ fun WebReader(
for (script in webReaderViewModel.javascriptDispatchQueue) {
Log.d("js", "executing script: $script")
it.evaluateJavascript(script, null)
if (script.contains("event.isDark")) {
if (script.contains("event.isDark = 'true'")) {
it.setBackgroundColor(Color.Transparent.hashCode())
} else {
it.setBackgroundColor(Color.White.hashCode())
}
}
}
webReaderViewModel.resetJavascriptDispatchQueue()
}

View file

@ -2,7 +2,6 @@ package app.omnivore.omnivore.ui.reader
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
@ -11,45 +10,45 @@ import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.material.TopAppBar
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import app.omnivore.omnivore.MainActivity
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.ui.components.LabelsSelectionSheetContent
import app.omnivore.omnivore.ui.components.WebReaderLabelsSelectionSheet
import app.omnivore.omnivore.ui.notebook.NotebookView
import app.omnivore.omnivore.ui.notebook.NotebookViewModel
import app.omnivore.omnivore.ui.savedItemViews.SavedItemContextMenu
import app.omnivore.omnivore.ui.theme.OmnivoreTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.ui.notebook.NotebookActivity
@AndroidEntryPoint
class WebReaderLoadingContainerActivity: ComponentActivity() {
val viewModel: WebReaderViewModel by viewModels()
val notebookViewModel: NotebookViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -83,6 +82,7 @@ class WebReaderLoadingContainerActivity: ComponentActivity() {
slug = slug,
onLibraryIconTap = if (requestID != null) { { startMainActivity() } } else null,
webReaderViewModel = viewModel,
notebookViewModel = notebookViewModel,
)
}
}
@ -105,24 +105,43 @@ class WebReaderLoadingContainerActivity: ComponentActivity() {
}
}
enum class BottomSheetState(
) {
NONE(),
PREFERENCES(),
NOTEBOOK(),
HIGHLIGHTNOTE(),
LABELS(),
}
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, onLibraryIconTap: (() -> Unit)? = null, webReaderViewModel: WebReaderViewModel) {
fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null,
onLibraryIconTap: (() -> Unit)? = null,
webReaderViewModel: WebReaderViewModel,
notebookViewModel: NotebookViewModel) {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
var isMenuExpanded by remember { mutableStateOf(false) }
var showWebPreferencesDialog by remember { mutableStateOf(false ) }
var bottomSheetState by remember { mutableStateOf(BottomSheetState.NONE) }
val isDarkMode = isSystemInDarkTheme()
val currentThemeKey = webReaderViewModel.currentThemeKey.observeAsState()
val currentTheme = Themes.values().find { it.themeKey == currentThemeKey.value }
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false)
val toolbarHeightPx: Float by webReaderViewModel.currentToolbarHeightLiveData.observeAsState(0.0f)
val labels: List<SavedItemLabel> by webReaderViewModel.savedItemLabelsLiveData.observeAsState(listOf())
val maxToolbarHeight = 48.dp
val backgroundColor = if (isSystemInDarkTheme()) Color.Black else Color.White
webReaderViewModel.maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
webReaderViewModel.loadItem(slug = slug, requestID = requestID)
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val styledContent = webReaderParams?.let {
val webReaderContent = WebReaderContent(
@ -133,111 +152,240 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o
webReaderContent.styledContent()
} ?: null
Box(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.background(color = backgroundColor)
) {
if (styledContent != null) {
WebReader(
preferences = webReaderViewModel.storedWebPreferences(isSystemInDarkTheme()),
styledContent = styledContent,
webReaderViewModel = webReaderViewModel
)
val modalBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden,
)
TopAppBar(
modifier = Modifier
.height(height = with(LocalDensity.current) {
toolbarHeightPx.roundToInt().toDp()
}),
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
title = {},
navigationIcon = {
IconButton(onClick = {
onBackPressedDispatcher?.onBackPressed()
}) {
Icon(
imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back"
)
val themeBackgroundColor = currentTheme?.let {
if (it.themeKey == "System" && isDarkMode) {
Color(0xFF000000)
} else if (it.themeKey == "System" ) {
Color(0xFFFFFFFF)
} else {
Color(it.backgroundColor ?: 0xFFFFFFFF)
}
} ?: Color(0xFFFFFFFF)
val themeTintColor = currentTheme?.let {
if (it.themeKey == "System" && isDarkMode) {
Color(0xFFFFFFFF)
} else if (it.themeKey == "System" ) {
Color(0xFF000000)
} else {
Color(it.foregroundColor ?: 0xFF000000)
}
} ?: Color(0xFF000000)
annotation?.let {
bottomSheetState = BottomSheetState.HIGHLIGHTNOTE
coroutineScope.launch {
modalBottomSheetState.animateTo(ModalBottomSheetValue.Expanded)
}
}
val showLabelsSelector: Boolean by webReaderViewModel.showLabelsSelectionSheetLiveData.observeAsState(false)
if (showLabelsSelector) {
bottomSheetState = BottomSheetState.LABELS
coroutineScope.launch {
modalBottomSheetState.animateTo(ModalBottomSheetValue.HalfExpanded)
}
}
ModalBottomSheetLayout(
modifier = Modifier
.statusBarsPadding(),
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
when (bottomSheetState) {
BottomSheetState.PREFERENCES -> {
BottomSheetUI("Reader Preferences") {
ReaderPreferencesView(webReaderViewModel)
}
},
actions = {
if (onLibraryIconTap != null) {
IconButton(onClick = { onLibraryIconTap() }) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null
}
BottomSheetState.NOTEBOOK -> {
webReaderParams?.let { params ->
BottomSheetUI(title = "Notebook") {
NotebookView(savedItemId = params.item.savedItemId, viewModel = notebookViewModel)
}
}
}
BottomSheetState.HIGHLIGHTNOTE -> {
annotation?.let { annotation ->
BottomSheetUI(title = "Note") {
AnnotationEditView(
initialAnnotation = annotation,
onSave = {
webReaderViewModel.saveAnnotation(it)
coroutineScope.launch {
modalBottomSheetState.hide()
bottomSheetState = BottomSheetState.NONE
}
},
onCancel = {
webReaderViewModel.cancelAnnotationEdit()
coroutineScope.launch {
modalBottomSheetState.hide()
bottomSheetState = BottomSheetState.NONE
}
}
)
}
}
webReaderParams?.let {
}
app.omnivore.omnivore.ui.reader.BottomSheetState.LABELS -> {
BottomSheetUI(title = "Notebook") {
LabelsSelectionSheetContent(
labels = labels,
initialSelectedLabels = webReaderParams?.labels ?: listOf(),
onCancel = {
coroutineScope.launch {
modalBottomSheetState.hide()
bottomSheetState = BottomSheetState.NONE
}
},
isLibraryMode = false,
onSave = {
if (it != labels) {
webReaderViewModel.updateSavedItemLabels(
savedItemID = webReaderParams?.item?.savedItemId ?: "", labels = it
)
}
coroutineScope.launch {
modalBottomSheetState.hide()
bottomSheetState = BottomSheetState.NONE
}
},
onCreateLabel = { newLabelName, labelHexValue ->
webReaderViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
}
}
BottomSheetState.NONE -> {
}
}
Spacer(modifier = Modifier.weight(1.0F))
}
) {
Scaffold(
topBar = {
TopAppBar(
modifier = Modifier
.height(height = with(LocalDensity.current) {
toolbarHeightPx.roundToInt().toDp()
}),
backgroundColor = themeBackgroundColor,
elevation = 0.dp,
title = {},
navigationIcon = {
IconButton(onClick = {
val intent = Intent(context, NotebookActivity::class.java)
intent.putExtra("SAVED_ITEM_ID", it.item.savedItemId)
context.startActivity(intent)
onBackPressedDispatcher?.onBackPressed()
}) {
Icon(
painter = painterResource(id = R.drawable.notebook),
contentDescription = null
imageVector = Icons.Filled.ArrowBack,
modifier = Modifier,
contentDescription = "Back",
tint = themeTintColor
)
}
}
IconButton(onClick = { showWebPreferencesDialog = true }) {
Icon(
painter = painterResource(id = R.drawable.format_letter_case),
contentDescription = null
)
}
IconButton(onClick = { isMenuExpanded = true }) {
Icon(
painter = painterResource(id = R.drawable.dots_horizontal),
contentDescription = null
)
}
SavedItemContextMenu(
isExpanded = isMenuExpanded,
isArchived = webReaderParams!!.item.isArchived,
onDismiss = { isMenuExpanded = false },
actionHandler = {
webReaderViewModel.handleSavedItemAction(
webReaderParams!!.item.savedItemId,
it
},
actions = {
if (onLibraryIconTap != null) {
IconButton(onClick = { onLibraryIconTap() }) {
Icon(
imageVector = Icons.Default.Home,
contentDescription = null,
tint = themeTintColor,
)
}
}
webReaderParams?.let {
IconButton(onClick = {
coroutineScope.launch {
bottomSheetState = BottomSheetState.NOTEBOOK
modalBottomSheetState.animateTo(ModalBottomSheetValue.Expanded)
}
}) {
Icon(
painter = painterResource(id = R.drawable.notebook),
contentDescription = null,
tint = themeTintColor
)
}
}
IconButton(onClick = {
coroutineScope.launch {
bottomSheetState = BottomSheetState.PREFERENCES
modalBottomSheetState.animateTo(ModalBottomSheetValue.HalfExpanded)
}
}) {
Icon(
painter = painterResource(id = R.drawable.format_letter_case),
contentDescription = null,
tint = themeTintColor
)
}
)
IconButton(onClick = { isMenuExpanded = true }) {
Icon(
painter = painterResource(id = R.drawable.dots_horizontal),
contentDescription = null,
tint = themeTintColor
)
if (isMenuExpanded) {
webReaderParams?.let { params ->
SavedItemContextMenu(
isExpanded = isMenuExpanded,
isArchived = params.item.isArchived,
onDismiss = { isMenuExpanded = false },
actionHandler = {
webReaderViewModel.handleSavedItemAction(
params.item.savedItemId,
it
)
}
)
}
}
}
},
)
}
) { paddingValues ->
if (styledContent != null) {
WebReader(
styledContent = styledContent,
webReaderViewModel = webReaderViewModel
)
}
}
)
if (showWebPreferencesDialog) {
WebPreferencesDialog(
onDismiss = {
showWebPreferencesDialog = false
},
webReaderViewModel = webReaderViewModel
)
LaunchedEffect(shouldPopView) {
if (shouldPopView) {
onBackPressedDispatcher?.onBackPressed()
}
}
if (annotation != null) {
AnnotationEditView(
initialAnnotation = annotation!!,
onSave = {
webReaderViewModel.saveAnnotation(it)
},
onCancel = {
webReaderViewModel.cancelAnnotationEdit()
}
)
}
WebReaderLabelsSelectionSheet(webReaderViewModel)
}
}
LaunchedEffect(shouldPopView) {
if (shouldPopView) {
onBackPressedDispatcher?.onBackPressed()
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun BottomSheetUI(title: String?, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.wrapContentHeight()
.fillMaxWidth()
.clip(RoundedCornerShape(topEnd = 20.dp, topStart = 20.dp))
.background(Color.White)
.statusBarsPadding()
) {
Scaffold(
) { paddingValues ->
Box(modifier = Modifier
.fillMaxSize()) {
content()
}
}
}

View file

@ -6,9 +6,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.*
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.dataService.*
@ -24,6 +22,7 @@ import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import java.util.*
import javax.inject.Inject
@ -37,6 +36,15 @@ data class AnnotationWebViewMessage(
val annotation: String?
)
enum class Themes(val themeKey: String, val backgroundColor: Long, val foregroundColor: Long) {
SYSTEM("System", 0xFFFFFFFF, 0xFF000000),
LIGHT("Light", 0xFFFFFFFF, 0xFF000000),
SEPIA("Sepia", 0xFFFBF0D9, 0xFF000000),
DARK("Dark", 0xFF2F3030, 0xFFFFFFFF),
APOLLO("Apollo", 0xFF6A6968, 0xFFFFFFFF),
BLACK("Black", 0xFF000000, 0xFFFFFFFF),
}
@HiltViewModel
class WebReaderViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
@ -56,9 +64,6 @@ class WebReaderViewModel @Inject constructor(
val showLabelsSelectionSheetLiveData = MutableLiveData(false)
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
// "Sepia", "Apollo",
val systemThemeKeys = listOf("Light", "Black", "System")
var hasTappedExistingHighlight = false
var lastTapCoordinates: TapCoordinates? = null
private var isLoading = false
@ -256,6 +261,11 @@ class WebReaderViewModel @Inject constructor(
javascriptActionLoopUUIDLiveData.value = UUID.randomUUID()
}
val currentThemeKey: LiveData<String> = datastoreRepo
.themeKeyFlow
.distinctUntilChanged()
.asLiveData()
fun storedWebPreferences(isDarkMode: Boolean): WebPreferences = runBlocking {
val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize)
val storedLineHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight)
@ -288,59 +298,37 @@ class WebReaderViewModel @Inject constructor(
return storedThemePreference
}
fun updateStoredThemePreference(index: Int, isDarkMode: Boolean) {
val newThemeKey = themeKey(isDarkMode, systemThemeKeys[index])
fun updateStoredThemePreference(newThemeKey: String, isDarkMode: Boolean) {
Log.d("theme", "Setting theme key: ${newThemeKey}")
runBlocking {
datastoreRepo.putString(DatastoreKeys.preferredTheme, systemThemeKeys[index])
datastoreRepo.putString(DatastoreKeys.preferredTheme, newThemeKey)
}
val script = "var event = new Event('updateTheme');event.themeName = '$newThemeKey';document.dispatchEvent(event);"
enqueueScript(script)
}
fun updateFontSize(isIncrease: Boolean) {
val delta = if (isIncrease) 2 else -2
var newFontSize: Int
fun setFontSize(newFontSize: Int) {
runBlocking {
val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize)
newFontSize = ((storedFontSize ?: 12) + delta).coerceIn(8, 28)
datastoreRepo.putInt(DatastoreKeys.preferredWebFontSize, newFontSize)
}
// Get value from data store and then update it
val script = "var event = new Event('updateFontSize');event.fontSize = '$newFontSize';document.dispatchEvent(event);"
enqueueScript(script)
}
fun updateMaxWidthPercentage(isIncrease: Boolean) {
val delta = if (isIncrease) 10 else -10
var newMaxWidthPercentageValue: Int
fun setMaxWidthPercentage(newMaxWidthPercentageValue: Int) {
runBlocking {
val storedWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage)
newMaxWidthPercentageValue = ((storedWidth ?: 100) + delta).coerceIn(40, 100)
datastoreRepo.putInt(DatastoreKeys.preferredWebMaxWidthPercentage, newMaxWidthPercentageValue)
}
// Get value from data store and then update it
val script = "var event = new Event('updateMaxWidthPercentage');event.maxWidthPercentage = '$newMaxWidthPercentageValue';document.dispatchEvent(event);"
enqueueScript(script)
}
fun updateLineSpacing(isIncrease: Boolean) {
val delta = if (isIncrease) 25 else -25
var newLineHeight: Int
fun setLineHeight(newLineHeight: Int) {
runBlocking {
val storedHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight)
newLineHeight = ((storedHeight ?: 150) + delta).coerceIn(100, 300)
datastoreRepo.putInt(DatastoreKeys.preferredWebLineHeight, newLineHeight)
}
// Get value from data store and then update it
val script = "var event = new Event('updateLineHeight');event.lineHeight = '$newLineHeight';document.dispatchEvent(event);"
enqueueScript(script)
}

View file

@ -1,6 +1,7 @@
package app.omnivore.omnivore.ui.savedItemViews
import LabelChip
import android.util.Log
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
@ -21,8 +22,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import app.omnivore.omnivore.R
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.ui.components.LabelChipColors
import app.omnivore.omnivore.ui.library.LibraryViewModel
@ -36,8 +35,6 @@ import coil.compose.rememberAsyncImagePainter
fun SavedItemCard(savedItemViewModel: SavedItemViewModel, savedItem: SavedItemWithLabelsAndHighlights, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) {
val listState = rememberLazyListState()
val actionsMenuItem: SavedItemWithLabelsAndHighlights? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(null)
Column(
modifier = Modifier
.combinedClickable(

View file

@ -24,19 +24,19 @@ fun SavedItemContextMenu(
expanded = isExpanded,
onDismissRequest = onDismiss
) {
DropdownMenuItem(
text = { Text("Edit Labels") },
onClick = {
actionHandler(SavedItemAction.EditLabels)
onDismiss()
},
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.tag),
contentDescription = null
)
}
)
// DropdownMenuItem(
// text = { Text("Edit Labels") },
// onClick = {
// actionHandler(SavedItemAction.EditLabels)
// onDismiss()
// },
// leadingIcon = {
// Icon(
// painter = painterResource(id = R.drawable.tag),
// contentDescription = null
// )
// }
// )
DropdownMenuItem(
text = { Text(if (isArchived) "Unarchive" else "Archive") },
onClick = {

View file

@ -17,8 +17,6 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemLabel
import app.omnivore.omnivore.persistence.entities.TypeaheadCardData
import app.omnivore.omnivore.ui.components.LabelChipColors
import app.omnivore.omnivore.ui.library.SavedItemAction