Merge pull request #3871 from stefanosansone/feature/following-screen

Android - Following screen
This commit is contained in:
Jackson Harper 2024-04-28 22:49:13 -07:00 committed by GitHub
commit 44d3edc794
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
98 changed files with 5237 additions and 3515 deletions

View file

@ -27,8 +27,8 @@ android {
applicationId = "app.omnivore.omnivore"
minSdk = 26
targetSdk = 34
versionCode = 2000080
versionName = "0.200.8"
versionCode = 2010000
versionName = "0.201.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View file

@ -1,12 +1,12 @@
mutation SetLabels($input: SetLabelsInput!) {
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
...LabelFields
}
setLabels(input: $input) {
... on SetLabelsSuccess {
labels {
...LabelFields
}
}
... on SetLabelsError {
errorCodes
}
}
... on SetLabelsError {
errorCodes
}
}
}

View file

@ -1,12 +1,12 @@
mutation SetLinkArchived($input: ArchiveLinkInput!) {
setLinkArchived(input: $input) {
... on ArchiveLinkSuccess {
linkId
message
setLinkArchived(input: $input) {
... on ArchiveLinkSuccess {
linkId
message
}
... on ArchiveLinkError {
message
errorCodes
}
}
... on ArchiveLinkError {
message
errorCodes
}
}
}

View file

@ -1,70 +1,71 @@
query GetArticle($slug: String!) {
article(username: "me", slug: $slug) {
... on ArticleSuccess {
article {
...ArticleFields
content
highlights(input: { includeFriends: false }) {
...HighlightFields
article(username: "me", slug: $slug) {
... on ArticleSuccess {
article {
...ArticleFields
content
highlights(input: { includeFriends: false }) {
...HighlightFields
}
labels {
...LabelFields
}
}
}
labels {
...LabelFields
... on ArticleError {
errorCodes
}
}
}
... on ArticleError {
errorCodes
}
}
}
fragment ArticleFields on Article {
id
title
url
author
image
savedAt
createdAt
publishedAt
contentReader
originalArticleUrl
readingProgressPercent
readingProgressAnchorIndex
slug
isArchived
description
linkId
siteName
state
readAt
updatedAt
content
wordsCount
id
title
folder
url
author
image
savedAt
createdAt
publishedAt
contentReader
originalArticleUrl
readingProgressPercent
readingProgressAnchorIndex
slug
isArchived
description
linkId
siteName
state
readAt
updatedAt
content
wordsCount
}
fragment HighlightFields on Highlight {
id
type
shortId
quote
prefix
suffix
patch
annotation
createdByMe
createdAt
updatedAt
sharedAt
color
highlightPositionPercent
highlightPositionAnchorIndex
id
type
shortId
quote
prefix
suffix
patch
annotation
createdByMe
createdAt
updatedAt
sharedAt
color
highlightPositionPercent
highlightPositionAnchorIndex
}
fragment LabelFields on Label {
id
name
color
description
createdAt
id
name
color
description
createdAt
}

View file

@ -1,13 +1,13 @@
mutation CreateHighlight($input: CreateHighlightInput!) {
createHighlight(input: $input) {
... on CreateHighlightSuccess {
highlight {
...HighlightFields
}
}
createHighlight(input: $input) {
... on CreateHighlightSuccess {
highlight {
...HighlightFields
}
}
... on CreateHighlightError {
errorCodes
... on CreateHighlightError {
errorCodes
}
}
}
}

View file

@ -1,16 +1,16 @@
mutation CreateLabel($input: CreateLabelInput!) {
createLabel(input: $input) {
... on CreateLabelSuccess {
label {
id
name
color
description
createdAt
}
createLabel(input: $input) {
... on CreateLabelSuccess {
label {
id
name
color
description
createdAt
}
}
... on CreateLabelError {
errorCodes
}
}
... on CreateLabelError {
errorCodes
}
}
}

View file

@ -1,12 +1,12 @@
mutation DeleteHighlight($highlightId: ID!) {
deleteHighlight(highlightId: $highlightId) {
... on DeleteHighlightSuccess {
highlight {
id
}
deleteHighlight(highlightId: $highlightId) {
... on DeleteHighlightSuccess {
highlight {
id
}
}
... on DeleteHighlightError {
errorCodes
}
}
... on DeleteHighlightError {
errorCodes
}
}
}

View file

@ -1,12 +1,12 @@
mutation SetBookmarkArticle($input: SetBookmarkArticleInput!) {
setBookmarkArticle(input: $input) {
... on SetBookmarkArticleSuccess {
bookmarkedArticle {
id
}
setBookmarkArticle(input: $input) {
... on SetBookmarkArticleSuccess {
bookmarkedArticle {
id
}
}
... on SetBookmarkArticleError {
errorCodes
}
}
... on SetBookmarkArticleError {
errorCodes
}
}
}

View file

@ -1,12 +1,12 @@
query GetLabels {
labels {
... on LabelsSuccess {
labels {
...LabelFields
}
labels {
... on LabelsSuccess {
labels {
...LabelFields
}
}
... on LabelsError {
errorCodes
}
}
... on LabelsError {
errorCodes
}
}
}

View file

@ -1,23 +1,23 @@
mutation MergeHighlight($input: MergeHighlightInput!) {
mergeHighlight(input: $input) {
... on MergeHighlightSuccess {
highlight {
id
shortId
quote
prefix
suffix
patch
createdAt
updatedAt
annotation
sharedAt
createdByMe
}
overlapHighlightIdList
mergeHighlight(input: $input) {
... on MergeHighlightSuccess {
highlight {
id
shortId
quote
prefix
suffix
patch
createdAt
updatedAt
annotation
sharedAt
createdByMe
}
overlapHighlightIdList
}
... on MergeHighlightError {
errorCodes
}
}
... on MergeHighlightError {
errorCodes
}
}
}

View file

@ -1,14 +1,14 @@
mutation SaveArticleReadingProgress($input: SaveArticleReadingProgressInput!) {
saveArticleReadingProgress(input: $input) {
... on SaveArticleReadingProgressSuccess {
updatedArticle {
id
readingProgressPercent
readingProgressAnchorIndex
}
saveArticleReadingProgress(input: $input) {
... on SaveArticleReadingProgressSuccess {
updatedArticle {
id
readingProgressPercent
readingProgressAnchorIndex
}
}
... on SaveArticleReadingProgressError {
errorCodes
}
}
... on SaveArticleReadingProgressError {
errorCodes
}
}
}

View file

@ -1,10 +1,10 @@
mutation SaveUrl($input: SaveUrlInput!) {
saveUrl(input: $input) {
... on SaveSuccess {
url
saveUrl(input: $input) {
... on SaveSuccess {
url
}
... on SaveError {
errorCodes
}
}
... on SaveError {
errorCodes
}
}
}

View file

@ -1,56 +1,57 @@
query Search($after: String, $first: Int, $query: String) {
search(first: $first, after: $after, query: $query, includeContent: true) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
...LabelFields
}
highlights {
...HighlightFields
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
updatedAt
wordsCount
content
search(first: $first, after: $after, query: $query, includeContent: true) {
... on SearchSuccess {
edges {
cursor
node {
id
title
slug
url
folder
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
...LabelFields
}
highlights {
...HighlightFields
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
updatedAt
wordsCount
content
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on SearchError {
errorCodes
}
}
}

View file

@ -1,15 +1,15 @@
query TypeaheadSearch($query: String!) {
typeaheadSearch(query: $query) {
... on TypeaheadSearchSuccess {
items {
id
title
slug
siteName
}
typeaheadSearch(query: $query) {
... on TypeaheadSearchSuccess {
items {
id
title
slug
siteName
}
}
... on TypeaheadSearchError {
errorCodes
}
}
... on TypeaheadSearchError {
errorCodes
}
}
}

View file

@ -1,13 +1,13 @@
mutation UpdateHighlight($input: UpdateHighlightInput!) {
updateHighlight(input: $input) {
... on UpdateHighlightSuccess {
highlight {
id
}
}
updateHighlight(input: $input) {
... on UpdateHighlightSuccess {
highlight {
id
}
}
... on UpdateHighlightError {
errorCodes
... on UpdateHighlightError {
errorCodes
}
}
}
}

View file

@ -1,15 +1,15 @@
mutation UpdatePage($input: UpdatePageInput!) {
updatePage(input: $input) {
... on UpdatePageSuccess {
updatedPage {
title
author
description
}
}
updatePage(input: $input) {
... on UpdatePageSuccess {
updatedPage {
title
author
description
}
}
... on UpdatePageError {
errorCodes
... on UpdatePageError {
errorCodes
}
}
}
}

View file

@ -1,63 +1,64 @@
query UpdatesSince(
$folder: String
$after: String
$first: Int
$since: Date!
$folder: String
$after: String
$first: Int
$since: Date!
) {
updatesSince(after: $after, first: $first, folder: $folder, since: $since) {
... on UpdatesSinceSuccess {
edges {
cursor
itemID
updateReason
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
...LabelFields
}
highlights {
...HighlightFields
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
updatedAt
language
wordsCount
updatesSince(after: $after, first: $first, folder: $folder, since: $since) {
... on UpdatesSinceSuccess {
edges {
cursor
itemID
updateReason
node {
id
title
folder
slug
url
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
...LabelFields
}
highlights {
...HighlightFields
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
updatedAt
language
wordsCount
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on UpdatesSinceError {
errorCodes
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on UpdatesSinceError {
errorCodes
}
}
}

View file

@ -1,3 +1,3 @@
query ValidateUsername($username: String!) {
validateUsername(username: $username)
validateUsername(username: $username)
}

View file

@ -1,14 +1,14 @@
query Viewer {
me {
id
name
isFullUser
intercomHash
profile {
id
username
pictureUrl
bio
me {
id
name
isFullUser
intercomHash
profile {
id
username
pictureUrl
bio
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,6 @@ import android.view.View
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
@ -13,12 +12,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.library.SearchViewModel
import app.omnivore.omnivore.feature.root.RootView
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.theme.OmnivoreTheme
import com.pspdfkit.PSPDFKit
import dagger.hilt.android.AndroidEntryPoint
@ -36,12 +30,6 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
val loginViewModel: LoginViewModel by viewModels()
val searchViewModel: SearchViewModel by viewModels()
val labelsViewModel: LabelsViewModel by viewModels()
val saveViewModel: SaveViewModel by viewModels()
val editInfoViewModel: EditInfoViewModel by viewModels()
val context = this
GlobalScope.launch(Dispatchers.IO) {
@ -57,19 +45,11 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
OmnivoreTheme {
Box(
modifier = Modifier
.fillMaxSize()
modifier = Modifier.fillMaxSize()
) {
RootView(
loginViewModel,
searchViewModel,
labelsViewModel,
saveViewModel,
editInfoViewModel
)
RootView()
}
}
}

View file

@ -1,6 +1,7 @@
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
@ -8,152 +9,157 @@ import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighli
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.savedItemUpdates
import app.omnivore.omnivore.core.network.search
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
suspend fun DataService.librarySearch(cursor: String?, query: String): SearchResult {
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
val savedItems = searchResult.items.map {
SavedItemWithLabelsAndHighlights(
savedItem = it.item,
labels = it.labels,
highlights = it.highlights,
val savedItems = searchResult.items.map {
SavedItemWithLabelsAndHighlights(
savedItem = it.item,
labels = it.labels,
highlights = it.highlights,
)
}
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d(
"sync",
"found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor"
)
}
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d("sync", "found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor")
return SearchResult(
hasError = false,
hasMoreItems = false,
cursor = searchResult.cursor,
count = searchResult.items.size,
savedItems = savedItems
)
return SearchResult(
hasError = false,
hasMoreItems = false,
cursor = searchResult.cursor,
count = searchResult.items.size,
savedItems = savedItems
)
}
suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult {
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since)
?: return SavedItemSyncResult.errorResult
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since)
?: return SavedItemSyncResult.errorResult
if (syncResult.deletedItemIDs.isNotEmpty()) {
db.savedItemDao().deleteByIds(syncResult.deletedItemIDs)
}
if (syncResult.deletedItemIDs.isNotEmpty()) {
db.savedItemDao().deleteByIds(syncResult.deletedItemIDs)
}
val savedItems = syncResult.items.map {
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
createdAt = it.createdAt as String,
savedAt = it.savedAt as String,
readAt = it.readAt as String?,
updatedAt = it.updatedAt as String?,
readingProgress = it.readingProgressPercent,
readingProgressAnchor = it.readingProgressAnchorIndex,
imageURLString = it.image,
pageURLString = it.url,
descriptionText = it.description,
publisherURLString = it.originalArticleUrl,
siteName = it.siteName,
author = it.author,
publishDate = it.publishedAt as String?,
slug = it.slug,
isArchived = it.isArchived,
contentReader = it.contentReader.rawValue,
wordsCount = it.wordsCount
)
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
} ?: 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?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem,
labels = labels,
highlights = highlights
)
}
val savedItems = syncResult.items.map {
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
folder = it.folder,
createdAt = it.createdAt as String,
savedAt = it.savedAt as String,
readAt = it.readAt as String?,
updatedAt = it.updatedAt as String?,
readingProgress = it.readingProgressPercent,
readingProgressAnchor = it.readingProgressAnchorIndex,
imageURLString = it.image,
pageURLString = it.url,
descriptionText = it.description,
publisherURLString = it.originalArticleUrl,
siteName = it.siteName,
author = it.author,
publishDate = it.publishedAt as String?,
slug = it.slug,
isArchived = it.isArchived,
contentReader = it.contentReader.rawValue,
wordsCount = it.wordsCount
)
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
} ?: 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?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = labels, highlights = highlights
)
}
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
return SavedItemSyncResult(
hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug }
)
return SavedItemSyncResult(hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug })
}
fun DataService.isSavedItemContentStoredInDB(slug: String): Boolean {
val existingItem = db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
val content = existingItem?.savedItem?.content ?: ""
return content.length > 10
suspend fun DataService.isSavedItemContentStoredInDB(slug: String): Boolean {
val existingItem = db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
val content = existingItem?.savedItem?.content ?: ""
return content.length > 10
}
suspend fun DataService.fetchSavedItemContent(slug: String) {
val syncResult = networker.savedItem(slug)
val syncResult = networker.savedItem(slug)
val savedItem = syncResult.item
savedItem?.let {
val item = SavedItemWithLabelsAndHighlights(
savedItem = savedItem,
labels = syncResult.labels,
highlights = syncResult.highlights
)
db.savedItemWithLabelsAndHighlightsDao().insertAll(listOf(item))
}
val savedItem = syncResult.item
savedItem?.let {
val item = SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = syncResult.labels, highlights = syncResult.highlights
)
db.savedItemWithLabelsAndHighlightsDao().insertAll(listOf(item))
}
}
data class SavedItemSyncResult(
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItemSlugs: List<String>,
val cursor: String?
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItemSlugs: List<String>,
val cursor: String?
) {
companion object {
val errorResult = SavedItemSyncResult(hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItemSlugs = listOf())
}
companion object {
val errorResult = SavedItemSyncResult(
hasError = true,
hasMoreItems = true,
cursor = null,
count = 0,
savedItemSlugs = listOf()
)
}
}
data class SearchResult(
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItems: List<SavedItemWithLabelsAndHighlights>,
val cursor: String?
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItems: List<SavedItemWithLabelsAndHighlights>,
val cursor: String?
) {
companion object {
val errorResult = SearchResult(hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItems = listOf())
}
companion object {
val errorResult = SearchResult(
hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItems = listOf()
)
}
}

View file

@ -1,8 +0,0 @@
package app.omnivore.omnivore.core.data
import app.omnivore.omnivore.core.network.savedItemLabels
suspend fun DataService.syncLabels() {
val fetchedLabels = networker.savedItemLabels()
db.savedItemLabelDao().insertAll(fetchedLabels)
}

View file

@ -21,198 +21,204 @@ import com.apollographql.apollo3.api.Optional
import kotlinx.coroutines.delay
suspend fun DataService.startSyncChannels() {
Log.d("sync", "Starting sync channels")
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
Log.d("sync", "Starting sync channels")
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
}
suspend fun DataService.performHighlightChange(highlightChange: HighlightChange) {
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
db.highlightChangesDao().deleteById(highlight.highlightId)
}
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
db.highlightChangesDao().deleteById(highlight.highlightId)
}
}
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightChangesDao().getUnSynced()
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightChangesDao().getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
}
private suspend fun DataService.syncSavedItem(item: SavedItem) {
suspend fun updateSyncStatus(status: ServerSyncStatus) {
item.serverSyncStatus = status.rawValue
db.savedItemDao().update(item)
}
when (item.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteSavedItem(item.savedItemId)
if (isDeletedOnServer) {
db.savedItemDao().deleteById(item.savedItemId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
suspend fun updateSyncStatus(status: ServerSyncStatus) {
item.serverSyncStatus = status.rawValue
db.savedItemDao().update(item)
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isArchiveServerSynced = networker.updateArchiveStatusSavedItem(itemID = item.savedItemId, setAsArchived = item.isArchived)
when (item.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isReadingProgressSynced = networker.updateReadingProgress(
ReadingProgressParams(
id = item.savedItemId,
force = item.contentReader == "PDF",
readingProgressPercent = item.readingProgress,
readingProgressAnchorIndex = item.readingProgressAnchor
)
)
val isDeletedOnServer = networker.deleteSavedItem(item.savedItemId)
if (isArchiveServerSynced && isReadingProgressSynced) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
if (isDeletedOnServer) {
db.savedItemDao().deleteById(item.savedItemId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isArchiveServerSynced = networker.updateArchiveStatusSavedItem(
itemID = item.savedItemId, setAsArchived = item.isArchived
)
val isReadingProgressSynced = networker.updateReadingProgress(
ReadingProgressParams(
id = item.savedItemId,
force = item.contentReader == "PDF",
readingProgressPercent = item.readingProgress,
readingProgressAnchorIndex = item.readingProgressAnchor
)
)
if (isArchiveServerSynced && isReadingProgressSynced) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
// TODO: implement when we are able to create content on device
// updateSyncStatus(ServerSyncStatus.IS_SYNCING)
// send update to server
// update db
}
else -> return
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
// TODO: implement when we are able to create content on device
// updateSyncStatus(ServerSyncStatus.IS_SYNCING)
// send update to server
// update db
}
else -> return
}
}
private suspend fun DataService.syncHighlightChange(highlightChange: HighlightChange): Boolean {
val highlight = highlightChangeToHighlight(highlightChange)
val highlight = highlightChangeToHighlight(highlightChange)
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
}
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
db.highlightDao().deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isDeletedOnServer) {
db.highlightDao().deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
}
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
return if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
return true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
return false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
}

View file

@ -1,6 +1,7 @@
package app.omnivore.omnivore.core.data.model
data class LibraryQuery(
val folders: List<String>,
val allowedArchiveStates: List<Int>,
val sortKey: String,
val requiredLabels: List<String>,

View file

@ -1,6 +1,10 @@
package app.omnivore.omnivore.core.data.repository
import app.omnivore.omnivore.core.data.SavedItemSyncResult
import app.omnivore.omnivore.core.data.SearchResult
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import kotlinx.coroutines.flow.Flow
@ -8,9 +12,37 @@ interface LibraryRepository {
fun getSavedItems(query: LibraryQuery): Flow<List<SavedItemWithLabelsAndHighlights>>
fun getSavedItemsLabels(): Flow<List<SavedItemLabel>>
suspend fun getLabels(): List<SavedItemLabel>
suspend fun fetchSavedItemContent(slug: String)
suspend fun insertAllLabels(labels: List<SavedItemLabel>)
suspend fun setSavedItemLabels(itemId: String, labels: List<SavedItemLabel>): Boolean
suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
readingProgressAnchorIndex: Int
)
suspend fun createNewSavedItemLabel(labelName: String, hexColorValue: String)
suspend fun librarySearch(cursor: String?, query: String): SearchResult
suspend fun isSavedItemContentStoredInDB(slug: String): Boolean
suspend fun deleteSavedItem(itemID: String)
suspend fun archiveSavedItem(itemID: String)
suspend fun unarchiveSavedItem(itemID: String)
suspend fun syncOfflineItemsWithServerIfNeeded()
suspend fun syncHighlightChange(highlightChange: HighlightChange): Boolean
suspend fun sync(since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult
}

View file

@ -1,24 +1,67 @@
package app.omnivore.omnivore.core.data.repository.impl
import android.util.Log
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.SavedItemSyncResult
import app.omnivore.omnivore.core.data.SearchResult
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import app.omnivore.omnivore.core.database.dao.HighlightDao
import app.omnivore.omnivore.core.database.dao.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.dao.SavedItemLabelDao
import app.omnivore.omnivore.core.database.dao.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.highlightChangeToHighlight
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.archiveSavedItem
import app.omnivore.omnivore.core.network.createHighlight
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.core.network.deleteHighlights
import app.omnivore.omnivore.core.network.deleteSavedItem
import app.omnivore.omnivore.core.network.mergeHighlights
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.savedItemLabels
import app.omnivore.omnivore.core.network.savedItemUpdates
import app.omnivore.omnivore.core.network.search
import app.omnivore.omnivore.core.network.unarchiveSavedItem
import app.omnivore.omnivore.core.network.updateHighlight
import app.omnivore.omnivore.core.network.updateLabelsForSavedItem
import app.omnivore.omnivore.core.network.updateReadingProgress
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class LibraryRepositoryImpl @Inject constructor(
private val savedItemDao: SavedItemDao,
private val networker: Networker
private val savedItemLabelDao: SavedItemLabelDao,
private val savedItemWithLabelsAndHighlightsDao: SavedItemWithLabelsAndHighlightsDao,
private val savedItemAndSavedItemLabelCrossRefDao: SavedItemAndSavedItemLabelCrossRefDao,
private val highlightDao: HighlightDao,
private val highlightChangesDao: HighlightChangesDao,
private val networker: Networker,
private val dataService: DataService
): LibraryRepository {
override fun getSavedItems(query: LibraryQuery): Flow<List<SavedItemWithLabelsAndHighlights>> =
savedItemDao.filteredLibraryData(
folders = query.folders,
query.allowedArchiveStates,
query.sortKey,
hasRequiredLabels = query.requiredLabels.size,
@ -28,6 +71,26 @@ class LibraryRepositoryImpl @Inject constructor(
query.allowedContentReaders
)
override fun getSavedItemsLabels(): Flow<List<SavedItemLabel>> = savedItemLabelDao.getSavedItemLabels()
override suspend fun getLabels(): List<SavedItemLabel> = networker.savedItemLabels()
override suspend fun insertAllLabels(labels: List<SavedItemLabel>) {
savedItemLabelDao.insertAll(labels)
}
override suspend fun fetchSavedItemContent(slug: String) {
val syncResult = networker.savedItem(slug)
val savedItem = syncResult.item
savedItem?.let {
val item = SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = syncResult.labels, highlights = syncResult.highlights
)
savedItemWithLabelsAndHighlightsDao.insertAll(listOf(item))
}
}
override suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
@ -63,4 +126,361 @@ class LibraryRepositoryImpl @Inject constructor(
updatedItem?.let { savedItemDao.update(updatedItem) }
}
}
override suspend fun setSavedItemLabels(
itemId: String,
labels: List<SavedItemLabel>
): Boolean {
val input = SetLabelsInput(
pageId = itemId,
labels = Optional.presentIfNotNull(labels.map { CreateLabelInput(color = Optional.presentIfNotNull(it.color), name = it.name) }),
)
val updatedLabels = networker.updateLabelsForSavedItem(input)
// Figure out which of the labels are new
updatedLabels?.let { updatedLabels ->
val existingNamedLabels = savedItemLabelDao.namedLabels(updatedLabels.map { it.labelFields.name })
val existingNames = existingNamedLabels.map { it.name }
val newNamedLabels = updatedLabels.filter { !existingNames.contains(it.labelFields.name) }
savedItemLabelDao.insertAll(newNamedLabels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = null,
labelDescription = null
)
})
val allNamedLabels = savedItemLabelDao.namedLabels(updatedLabels.map { it.labelFields.name })
val crossRefs = allNamedLabels.map {
SavedItemAndSavedItemLabelCrossRef(
savedItemLabelId = it.savedItemLabelId,
savedItemId = itemId
)
}
savedItemAndSavedItemLabelCrossRefDao.deleteRefsBySavedItemId(itemId)
savedItemAndSavedItemLabelCrossRefDao.insertAll(crossRefs)
return true
} ?: run {
return false
}
}
override suspend fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
val newLabel = networker.createNewLabel(
CreateLabelInput(
color = Optional.presentIfNotNull(hexColorValue), name = labelName
)
)
newLabel?.let {
val savedItemLabel = SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = it.createdAt as String?,
labelDescription = it.description
)
savedItemLabelDao.insertAll(listOf(savedItemLabel))
}
}
override suspend fun librarySearch(cursor: String?, query: String): SearchResult {
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
val savedItems = searchResult.items.map {
SavedItemWithLabelsAndHighlights(
savedItem = it.item,
labels = it.labels,
highlights = it.highlights,
)
}
savedItemWithLabelsAndHighlightsDao.insertAll(savedItems)
Log.d(
"sync",
"found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor"
)
return SearchResult(
hasError = false,
hasMoreItems = false,
cursor = searchResult.cursor,
count = searchResult.items.size,
savedItems = savedItems
)
}
override suspend fun isSavedItemContentStoredInDB(slug: String): Boolean {
val existingItem = savedItemDao.getSavedItemWithLabelsAndHighlights(slug)
val content = existingItem?.savedItem?.content ?: ""
return content.length > 10
}
override suspend fun deleteSavedItem(itemID: String) {
val savedItem = savedItemDao.findById(itemID = itemID) ?: return
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
savedItemDao.update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
savedItemDao.deleteById(itemID)
}
}
override suspend fun archiveSavedItem(itemID: String) {
val savedItem = savedItemDao.findById(itemID = itemID) ?: return
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
savedItemDao.update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
savedItemDao.update(savedItem)
}
}
override suspend fun unarchiveSavedItem(itemID: String) {
val savedItem = savedItemDao.findById(itemID = itemID) ?: return
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
savedItemDao.update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
savedItemDao.update(savedItem)
}
}
override suspend fun syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = savedItemDao.getUnSynced()
val unSyncedHighlights = highlightChangesDao.getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
dataService.savedItemSyncChannel.send(savedItem)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
}
override suspend fun syncHighlightChange(highlightChange: HighlightChange): Boolean {
val highlight = highlightChangeToHighlight(highlightChange)
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
highlightDao.update(highlight)
}
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
highlightDao.deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
return true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
return false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
}
private suspend fun performHighlightChange(highlightChange: HighlightChange) {
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
highlightChangesDao.deleteById(highlight.highlightId)
}
}
override suspend fun sync(since: String, cursor: String?, limit: Int): SavedItemSyncResult {
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since)
?: return SavedItemSyncResult.errorResult
if (syncResult.deletedItemIDs.isNotEmpty()) {
savedItemDao.deleteByIds(syncResult.deletedItemIDs)
}
val savedItems = syncResult.items.map {
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
folder = it.folder,
createdAt = it.createdAt as String,
savedAt = it.savedAt as String,
readAt = it.readAt as String?,
updatedAt = it.updatedAt as String?,
readingProgress = it.readingProgressPercent,
readingProgressAnchor = it.readingProgressAnchorIndex,
imageURLString = it.image,
pageURLString = it.url,
descriptionText = it.description,
publisherURLString = it.originalArticleUrl,
siteName = it.siteName,
author = it.author,
publishDate = it.publishedAt as String?,
slug = it.slug,
isArchived = it.isArchived,
contentReader = it.contentReader.rawValue,
content = null,
wordsCount = it.wordsCount
)
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
} ?: 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?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = labels, highlights = highlights
)
}
savedItemWithLabelsAndHighlightsDao.insertAll(savedItems)
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
return SavedItemSyncResult(hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug })
}
}

View file

@ -2,19 +2,19 @@ package app.omnivore.omnivore.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import app.omnivore.omnivore.core.database.dao.HighlightDao
import app.omnivore.omnivore.core.database.dao.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.dao.SavedItemLabelDao
import app.omnivore.omnivore.core.database.dao.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.HighlightChangesDao
import app.omnivore.omnivore.core.database.entities.HighlightDao
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabelDao
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Viewer
import app.omnivore.omnivore.core.database.entities.ViewerDao
@ -27,7 +27,7 @@ import app.omnivore.omnivore.core.database.entities.ViewerDao
HighlightChange::class,
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class],
version = 26,
version = 27,
exportSchema = true
)
abstract class OmnivoreDatabase : RoomDatabase() {

View file

@ -0,0 +1,19 @@
package app.omnivore.omnivore.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.omnivore.omnivore.core.database.entities.HighlightChange
@Dao
interface HighlightChangesDao {
@Query("SELECT * FROM highlightChange WHERE serverSyncStatus != 0 ORDER BY updatedAt ASC")
fun getUnSynced(): List<HighlightChange>
@Query("DELETE FROM highlightChange WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<HighlightChange>)
}

View file

@ -0,0 +1,31 @@
package app.omnivore.omnivore.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.Highlight
@Dao
interface HighlightDao {
@Query("SELECT * FROM highlight WHERE serverSyncStatus != 0")
fun getUnSynced(): List<Highlight>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<Highlight>)
@Query("DELETE FROM highlight WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Query("SELECT * FROM highlight WHERE highlightId = :highlightId")
fun findById(highlightId: String): Highlight?
// Server sync status is passed in here to work around Room compile-time query rules, but should always be NEEDS_UPDATE
@Query("UPDATE highlight SET annotation = :note, serverSyncStatus = :serverSyncStatus WHERE highlightId = :highlightId")
fun updateNote(highlightId: String, note: String, serverSyncStatus: Int = ServerSyncStatus.NEEDS_UPDATE.rawValue)
@Update
fun update(highlight: Highlight)
}

View file

@ -0,0 +1,16 @@
package app.omnivore.omnivore.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
@Dao
interface SavedItemAndSavedItemLabelCrossRefDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId")
suspend fun deleteRefsBySavedItemId(savedItemId: String)
}

View file

@ -23,10 +23,10 @@ interface SavedItemDao {
fun getUnSynced(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE slug = :slug")
fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
suspend fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
@Query("DELETE FROM savedItem WHERE savedItemId = :itemID")
fun deleteById(itemID: String)
suspend fun deleteById(itemID: String)
@Query("DELETE FROM savedItem WHERE savedItemId in (:itemIDs)")
fun deleteByIds(itemIDs: List<String>)
@ -77,10 +77,16 @@ interface SavedItemDao {
"LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " +
"WHERE SavedItem.serverSyncStatus != 2 " +
"AND SavedItem.folder IN (:folders) " +
"AND SavedItem.isArchived IN (:allowedArchiveStates) " +
"AND SavedItem.contentReader IN (:allowedContentReaders) " +
"AND CASE WHEN :hasRequiredLabels THEN SavedItemLabel.name in (:requiredLabels) ELSE 1 END " +
"AND CASE WHEN :hasExcludedLabels THEN SavedItemLabel.name is NULL OR SavedItemLabel.name not in (:excludedLabels) ELSE 1 END " +
"AND CASE WHEN :hasExcludedLabels THEN NOT EXISTS ( " +
" SELECT 1 FROM SavedItemAndSavedItemLabelCrossRef " +
" INNER JOIN SavedItemLabel ON SavedItemAndSavedItemLabelCrossRef.savedItemLabelId = SavedItemLabel.savedItemLabelId " +
" WHERE SavedItemAndSavedItemLabelCrossRef.savedItemId = SavedItem.savedItemId " +
" AND SavedItemLabel.name IN (:excludedLabels) " +
") ELSE 1 END " +
"GROUP BY SavedItem.savedItemId " +
@ -92,6 +98,7 @@ interface SavedItemDao {
"CASE WHEN :sortKey = 'recentlyPublished' THEN SavedItem.publishDate END DESC"
)
fun filteredLibraryData(
folders: List<String>,
allowedArchiveStates: List<Int>,
sortKey: String,
hasRequiredLabels: Int,

View file

@ -0,0 +1,35 @@
package app.omnivore.omnivore.core.database.dao
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import kotlinx.coroutines.flow.Flow
@Dao
interface SavedItemLabelDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(items: List<SavedItemLabel>)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC")
fun getSavedItemLabels(): Flow<List<SavedItemLabel>>
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC")
fun getSavedItemLabelsLiveData(): LiveData<List<SavedItemLabel>>
@Transaction
@Query("UPDATE SavedItemLabel set savedItemLabelId = :permanentId, serverSyncStatus = :status WHERE savedItemLabelId = :tempId")
fun updateTempLabel(
tempId: String, permanentId: String, status: ServerSyncStatus = ServerSyncStatus.IS_SYNCED
)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE name in (:names) ORDER BY name ASC")
suspend fun namedLabels(names: List<String>): List<SavedItemLabel>
}

View file

@ -0,0 +1,70 @@
package app.omnivore.omnivore.core.database.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Transaction
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
@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 suspend 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)
}
}

View file

@ -1,6 +1,15 @@
package app.omnivore.omnivore.core.database.entities
import androidx.room.*
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.Junction
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import com.google.gson.annotations.SerializedName
@ -87,25 +96,3 @@ data class SavedItemWithLabelsAndHighlights(
return savedItem.savedItemId.hashCode()
}
}
@Dao
interface HighlightDao {
@Query("SELECT * FROM highlight WHERE serverSyncStatus != 0")
fun getUnSynced(): List<Highlight>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<Highlight>)
@Query("DELETE FROM highlight WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Query("SELECT * FROM highlight WHERE highlightId = :highlightId")
fun findById(highlightId: String): Highlight?
// Server sync status is passed in here to work around Room compile-time query rules, but should always be NEEDS_UPDATE
@Query("UPDATE highlight SET annotation = :note, serverSyncStatus = :serverSyncStatus WHERE highlightId = :highlightId")
fun updateNote(highlightId: String, note: String, serverSyncStatus: Int = ServerSyncStatus.NEEDS_UPDATE.rawValue)
@Update
fun update(highlight: Highlight)
}

View file

@ -1,15 +1,12 @@
package app.omnivore.omnivore.core.database.entities
import android.util.Log
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
@ -110,16 +107,3 @@ fun highlightChangeToHighlight(change: HighlightChange): Highlight {
serverSyncStatus = change.serverSyncStatus
)
}
@Dao
interface HighlightChangesDao {
@Query("SELECT * FROM highlightChange WHERE serverSyncStatus != 0 ORDER BY updatedAt ASC")
fun getUnSynced(): List<HighlightChange>
@Query("DELETE FROM highlightChange WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<HighlightChange>)
}

View file

@ -2,17 +2,14 @@ package app.omnivore.omnivore.core.database.entities
import androidx.core.net.toUri
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Transaction
@Entity
data class SavedItem(
@PrimaryKey val savedItemId: String,
val title: String,
val folder: String,
val createdAt: String,
val savedAt: String,
val readAt: String?,
@ -74,74 +71,11 @@ 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)
}
}
object SavedItemQueryConstants {
const val columns =
"savedItemId, slug, publisherURLString, title, author, descriptionText, imageURLString, isArchived, pageURLString, contentReader, savedAt, readingProgress, wordsCount"
const val libraryColumns = "SavedItem.savedItemId, " +
"SavedItem.slug, " +
"SavedItem.createdAt, " +
"SavedItem.folder, " +
"SavedItem.publisherURLString, " +
"SavedItem.title, " +
"SavedItem.author, " +

View file

@ -1,15 +1,8 @@
package app.omnivore.omnivore.core.database.entities
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
@Entity
data class SavedItemLabel(
@ -21,26 +14,6 @@ data class SavedItemLabel(
val serverSyncStatus: Int = 0
)
@Dao
interface SavedItemLabelDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemLabel>)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC")
fun getSavedItemLabelsLiveData(): LiveData<List<SavedItemLabel>>
@Transaction
@Query("UPDATE SavedItemLabel set savedItemLabelId = :permanentId, serverSyncStatus = :status WHERE savedItemLabelId = :tempId")
fun updateTempLabel(
tempId: String, permanentId: String, status: ServerSyncStatus = ServerSyncStatus.IS_SYNCED
)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE name in (:names) ORDER BY name ASC")
fun namedLabels(names: List<String>): List<SavedItemLabel>
}
@Entity(
primaryKeys = ["savedItemLabelId", "savedItemId"], foreignKeys = [ForeignKey(
entity = SavedItem::class,
@ -56,16 +29,3 @@ interface SavedItemLabelDao {
data class SavedItemAndSavedItemLabelCrossRef(
val savedItemLabelId: String, val savedItemId: String
)
@Dao
interface SavedItemAndSavedItemLabelCrossRefDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId")
fun deleteRefsBySavedItemId(savedItemId: String)
}
// has many highlights
// has many savedItems

View file

@ -0,0 +1,18 @@
package app.omnivore.omnivore.core.datastore
const val omnivoreSelfHostedApiServer = "omnivoreSelfHostedAPIServer"
const val omnivoreSelfHostedWebServer = "omnivoreSelfHostedWebServer"
const val omnivoreAuthToken = "omnivoreAuthToken"
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
const val omnivorePendingUserToken = "omnivorePendingUserToken"
const val libraryLastSyncTimestamp = "libraryLastSyncTimestamp"
const val preferredWebFontSize = "preferredWebFontSize"
const val preferredWebLineHeight = "preferredWebLineHeight"
const val preferredWebMaxWidthPercentage = "preferredWebMaxWidthPercentage"
const val preferredWebFontFamily = "preferredWebFontFamily"
const val prefersWebHighContrastText = "prefersWebHighContrastText"
const val prefersJustifyText = "prefersJustifyText"
const val lastUsedSavedItemFilter = "lastUsedSavedItemFilter"
const val lastUsedSavedItemSortFilter = "lastUsedSavedItemSortFilter"
const val preferredTheme = "preferredTheme"
const val followingTabActive = "followingTabActive"

View file

@ -2,79 +2,96 @@ package app.omnivore.omnivore.core.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.*
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
interface DatastoreRepository {
val hasAuthTokenFlow: Flow<Boolean>
val themeKeyFlow: Flow<String>
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)
suspend fun getString(key: String): String?
suspend fun getInt(key: String): Int?
suspend fun clearValue(key: String)
suspend fun clear()
suspend fun putBoolean(key: String, value: Boolean)
fun getBoolean(key: String): Flow<Boolean>
suspend fun putString(key: String, value: String)
suspend fun putInt(key: String, value: Int)
suspend fun getString(key: String): String?
suspend fun getInt(key: String): Int?
suspend fun clearValue(key: String)
}
class OmnivoreDatastore @Inject constructor(
private val context: Context
private val context: Context
) : DatastoreRepository {
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = Constants.dataStoreName
)
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = Constants.dataStoreName
)
override suspend fun putString(key: String, value: String) {
val preferencesKey = stringPreferencesKey(key)
context.dataStore.edit { preferences ->
preferences[preferencesKey] = value
}
}
override suspend fun putInt(key: String, value: Int) {
val preferencesKey = intPreferencesKey(key)
context.dataStore.edit { preferences ->
preferences[preferencesKey] = value
}
}
override suspend fun getString(key: String): String? {
val preferencesKey = stringPreferencesKey(key)
val preferences = context.dataStore.data.first()
return preferences[preferencesKey]
}
override suspend fun getInt(key: String): Int? {
val preferencesKey = intPreferencesKey(key)
val preferences = context.dataStore.data.first()
return preferences[preferencesKey]
}
override suspend fun clear() {
context.dataStore.edit { it.clear() }
}
override suspend fun clearValue(key: String) {
val preferencesKey = stringPreferencesKey(key)
context.dataStore.edit { it.remove(preferencesKey) }
}
override val hasAuthTokenFlow: Flow<Boolean> = context
.dataStore.data.map { preferences ->
val key = stringPreferencesKey(DatastoreKeys.omnivoreAuthToken)
val token = preferences[key]
token != null
override suspend fun putBoolean(key: String, value: Boolean) {
val preferencesKey = booleanPreferencesKey(key)
context.dataStore.edit { preferences ->
preferences[preferencesKey] = value
}
}
override val themeKeyFlow: Flow<String> = context
.dataStore.data.map { preferences ->
val key = stringPreferencesKey(DatastoreKeys.preferredTheme)
preferences[key] ?: "System"
override fun getBoolean(key: String): Flow<Boolean> {
val preferencesKey = booleanPreferencesKey(key)
return context.dataStore.data.map { preferences ->
preferences[preferencesKey] ?: false
}
}
override suspend fun putString(key: String, value: String) {
val preferencesKey = stringPreferencesKey(key)
context.dataStore.edit { preferences ->
preferences[preferencesKey] = value
}
}
override suspend fun putInt(key: String, value: Int) {
val preferencesKey = intPreferencesKey(key)
context.dataStore.edit { preferences ->
preferences[preferencesKey] = value
}
}
override suspend fun getString(key: String): String? {
val preferencesKey = stringPreferencesKey(key)
val preferences = context.dataStore.data.first()
return preferences[preferencesKey]
}
override suspend fun getInt(key: String): Int? {
val preferencesKey = intPreferencesKey(key)
val preferences = context.dataStore.data.first()
return preferences[preferencesKey]
}
override suspend fun clear() {
context.dataStore.edit { it.clear() }
}
override suspend fun clearValue(key: String) {
val preferencesKey = stringPreferencesKey(key)
context.dataStore.edit { it.remove(preferencesKey) }
}
override val hasAuthTokenFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
val key = stringPreferencesKey(omnivoreAuthToken)
val token = preferences[key]
token != null
}
override val themeKeyFlow: Flow<String> = context.dataStore.data.map { preferences ->
val key = stringPreferencesKey(preferredTheme)
preferences[key] ?: "System"
}
}

View file

@ -0,0 +1,128 @@
package app.omnivore.omnivore.core.designsystem.component
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.StartOffset
import androidx.compose.animation.core.StartOffsetType
import androidx.compose.animation.core.repeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
val LocalPreferenceHighlighted = compositionLocalOf(structuralEqualityPolicy()) { false }
val LocalPreferenceMinHeight = compositionLocalOf(structuralEqualityPolicy()) { 56.dp }
@Composable
internal fun BasePreferenceWidget(
modifier: Modifier = Modifier,
title: String? = null,
subcomponent: @Composable (ColumnScope.() -> Unit)? = null,
icon: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
widget: @Composable (() -> Unit)? = null,
) {
val highlighted = LocalPreferenceHighlighted.current
val minHeight = LocalPreferenceMinHeight.current
Row(
modifier = modifier
.highlightBackground(highlighted)
.sizeIn(minHeight = minHeight)
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
Box(
modifier = Modifier.padding(start = PrefsHorizontalPadding, end = 8.dp),
content = { icon() },
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = PrefsVerticalPadding),
) {
if (!title.isNullOrBlank()) {
Text(
modifier = Modifier.padding(horizontal = PrefsHorizontalPadding),
text = title,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.titleLarge,
fontSize = TitleFontSize,
)
}
subcomponent?.invoke(this)
}
if (widget != null) {
Box(
modifier = Modifier.padding(end = PrefsHorizontalPadding),
content = { widget() },
)
}
}
}
internal fun Modifier.highlightBackground(highlighted: Boolean): Modifier = composed {
var highlightFlag by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (highlighted) {
highlightFlag = true
delay(3.seconds)
highlightFlag = false
}
}
val highlight by animateColorAsState(
targetValue = if (highlightFlag) {
MaterialTheme.colorScheme.surfaceTint.copy(alpha = .12f)
} else {
Color.Transparent
},
animationSpec = if (highlightFlag) {
repeatable(
iterations = 5,
animation = tween(durationMillis = 200),
repeatMode = RepeatMode.Reverse,
initialStartOffset = StartOffset(
offsetMillis = 600,
offsetType = StartOffsetType.Delay,
),
)
} else {
tween(200)
},
label = "highlight",
)
Modifier.background(color = highlight)
}
internal val TrailingWidgetBuffer = 16.dp
internal val PrefsHorizontalPadding = 16.dp
internal val PrefsVerticalPadding = 16.dp
internal val TitleFontSize = 16.sp

View file

@ -0,0 +1,69 @@
package app.omnivore.omnivore.core.designsystem.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Preview
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.PreviewLightDark
@Composable
fun SwitchPreferenceWidget(
modifier: Modifier = Modifier,
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
checked: Boolean = false,
onCheckedChanged: (Boolean) -> Unit,
) {
TextPreferenceWidget(
modifier = modifier,
title = title,
subtitle = subtitle,
icon = icon,
widget = {
Switch(
checked = checked,
onCheckedChange = null,
modifier = Modifier.padding(start = TrailingWidgetBuffer),
)
},
onPreferenceClick = { onCheckedChanged(!checked) },
)
}
@PreviewLightDark
@Composable
private fun SwitchPreferenceWidgetPreview() {
Surface {
Column {
SwitchPreferenceWidget(
title = "Text preference with icon",
subtitle = "Text preference summary",
icon = Icons.Filled.Preview,
checked = true,
onCheckedChanged = {},
)
SwitchPreferenceWidget(
title = "Text preference",
subtitle = "Text preference summary",
checked = false,
onCheckedChanged = {},
)
SwitchPreferenceWidget(
title = "Text preference no summary",
checked = false,
onCheckedChanged = {},
)
SwitchPreferenceWidget(
title = "Another text preference no summary",
checked = false,
onCheckedChanged = {},
)
}
}
}

View file

@ -0,0 +1,79 @@
package app.omnivore.omnivore.core.designsystem.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Preview
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.PreviewLightDark
import app.omnivore.omnivore.core.designsystem.util.secondaryItemAlpha
@Composable
fun TextPreferenceWidget(
modifier: Modifier = Modifier,
title: String? = null,
subtitle: String? = null,
icon: ImageVector? = null,
iconTint: Color = MaterialTheme.colorScheme.primary,
widget: @Composable (() -> Unit)? = null,
onPreferenceClick: (() -> Unit)? = null,
) {
BasePreferenceWidget(
modifier = modifier,
title = title,
subcomponent = if (!subtitle.isNullOrBlank()) {
{
Text(
text = subtitle,
modifier = Modifier
.padding(horizontal = PrefsHorizontalPadding)
.secondaryItemAlpha(),
style = MaterialTheme.typography.bodySmall,
maxLines = 10,
)
}
} else {
null
},
icon = if (icon != null) {
{
Icon(
imageVector = icon,
tint = iconTint,
contentDescription = null,
)
}
} else {
null
},
onClick = onPreferenceClick,
widget = widget,
)
}
@PreviewLightDark
@Composable
private fun TextPreferenceWidgetPreview() {
Surface {
Column {
TextPreferenceWidget(
title = "Text preference with icon",
subtitle = "Text preference summary",
icon = Icons.Filled.Preview,
onPreferenceClick = {},
)
TextPreferenceWidget(
title = "Text preference",
subtitle = "Text preference summary",
onPreferenceClick = {},
)
}
}
}

View file

@ -0,0 +1,12 @@
package app.omnivore.omnivore.core.designsystem.icon
import app.omnivore.omnivore.R
object OmnivoreIcons {
val Following = R.drawable.ic_stacks_rounded_fill
val FollowingEmpty = R.drawable.ic_stacks_rounded_empty
val Inbox = R.drawable.ic_bookmarks_rounded_fill
val InboxEmpty = R.drawable.ic_bookmarks_rounded_empty
val Profile = R.drawable.ic_person_rounded_fill
val ProfileEmpty = R.drawable.ic_person_rounded_empty
}

View file

@ -0,0 +1,61 @@
package app.omnivore.omnivore.core.designsystem.motion
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutLinearInEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
private const val ProgressThreshold = 0.35f
private val Int.ForOutgoing: Int
get() = (this * ProgressThreshold).toInt()
private val Int.ForIncoming: Int
get() = this - this.ForOutgoing
/**
* [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition.
*/
fun materialSharedAxisXIn(
initialOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): EnterTransition = slideInHorizontally(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
initialOffsetX = initialOffsetX
) + fadeIn(
animationSpec = tween(
durationMillis = durationMillis.ForIncoming,
delayMillis = durationMillis.ForOutgoing,
easing = LinearOutSlowInEasing
)
)
/**
* [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition.
*
*/
fun materialSharedAxisXOut(
targetOffsetX: (fullWidth: Int) -> Int,
durationMillis: Int = MotionConstants.DefaultMotionDuration,
): ExitTransition = slideOutHorizontally(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutSlowInEasing
),
targetOffsetX = targetOffsetX
) + fadeOut(
animationSpec = tween(
durationMillis = durationMillis.ForOutgoing,
delayMillis = 0,
easing = FastOutLinearInEasing
)
)

View file

@ -0,0 +1,11 @@
package app.omnivore.omnivore.core.designsystem.motion
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object MotionConstants {
const val DefaultMotionDuration: Int = 300
const val DefaultFadeInDuration: Int = 150
const val DefaultFadeOutDuration: Int = 75
val DefaultSlideDistance: Dp = 30.dp
}

View file

@ -0,0 +1,3 @@
package app.omnivore.omnivore.core.designsystem.util
const val SecondaryItemAlpha = .78f

View file

@ -0,0 +1,6 @@
package app.omnivore.omnivore.core.designsystem.util
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)

View file

@ -1,8 +1,9 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.omnivoreAuthToken
import app.omnivore.omnivore.core.datastore.omnivoreSelfHostedApiServer
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.ApolloClient
import javax.inject.Inject
@ -10,10 +11,10 @@ class Networker @Inject constructor(
private val datastoreRepo: DatastoreRepository
) {
suspend fun baseUrl() =
datastoreRepo.getString(DatastoreKeys.omnivoreSelfHostedAPIServer) ?: Constants.apiURL
datastoreRepo.getString(omnivoreSelfHostedApiServer) ?: Constants.apiURL
private suspend fun serverUrl() = "${baseUrl()}/api/graphql"
private suspend fun authToken() = datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken) ?: ""
private suspend fun authToken() = datastoreRepo.getString(omnivoreAuthToken) ?: ""
suspend fun authenticatedApolloClient() = ApolloClient.Builder().serverUrl(serverUrl())
.addHttpHeader("Authorization", value = authToken()).build()

View file

@ -1,49 +1,41 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation
import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput
import android.util.Log
import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation
import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
data class ReadingProgressParams(
val id: String?,
val readingProgressPercent: Double?,
val readingProgressAnchorIndex: Int?,
val force: Boolean?
val id: String?,
val readingProgressPercent: Double?,
val readingProgressAnchorIndex: Int?,
val force: Boolean?
) {
fun asSaveReadingProgressInput() = SaveArticleReadingProgressInput(
id = id ?: "",
force = Optional.presentIfNotNull(force),
readingProgressPercent = readingProgressPercent ?: 0.0,
readingProgressAnchorIndex = Optional.presentIfNotNull(readingProgressAnchorIndex ?: 0)
)
}
suspend fun Networker.updateWebReadingProgress(jsonString: String): Boolean {
val params = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
return updateReadingProgress(params)
fun asSaveReadingProgressInput() = SaveArticleReadingProgressInput(
id = id ?: "",
force = Optional.presentIfNotNull(force),
readingProgressPercent = readingProgressPercent ?: 0.0,
readingProgressAnchorIndex = Optional.presentIfNotNull(readingProgressAnchorIndex ?: 0)
)
}
suspend fun Networker.updateReadingProgress(params: ReadingProgressParams): Boolean {
try {
val input = params.asSaveReadingProgressInput()
try {
val input = params.asSaveReadingProgressInput()
Log.d("Loggo", "created reading progress input: $input")
Log.d("Loggo", "created reading progress input: $input")
val result = authenticatedApolloClient()
.mutation(SaveArticleReadingProgressMutation(input))
.execute()
val result = authenticatedApolloClient().mutation(SaveArticleReadingProgressMutation(input))
.execute()
val articleID =
result.data?.saveArticleReadingProgress?.onSaveArticleReadingProgressSuccess?.updatedArticle?.id
val articleID =
result.data?.saveArticleReadingProgress?.onSaveArticleReadingProgressSuccess?.updatedArticle?.id
Log.d("Loggo", "updated article with id: $articleID")
Log.d("Loggo", "updated article with id: $articleID")
return articleID != null
} catch (e: java.lang.Exception) {
return false
}
return articleID != null
} catch (e: java.lang.Exception) {
return false
}
}

View file

@ -6,19 +6,19 @@ import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
suspend fun Networker.updateLabelsForSavedItem(input: SetLabelsInput): List<SetLabelsMutation.Label>? {
return try {
val result = authenticatedApolloClient().mutation(SetLabelsMutation(input)).execute()
return result.data?.setLabels?.onSetLabelsSuccess?.labels
} catch (e: java.lang.Exception) {
return null
}
return try {
val result = authenticatedApolloClient().mutation(SetLabelsMutation(input)).execute()
result.data?.setLabels?.onSetLabelsSuccess?.labels
} catch (e: java.lang.Exception) {
null
}
}
suspend fun Networker.createNewLabel(input: CreateLabelInput): CreateLabelMutation.Label? {
return try {
val result = authenticatedApolloClient().mutation(CreateLabelMutation(input)).execute()
return result.data?.createLabel?.onCreateLabelSuccess?.label
} catch (e: java.lang.Exception) {
null
}
return try {
val result = authenticatedApolloClient().mutation(CreateLabelMutation(input)).execute()
return result.data?.createLabel?.onCreateLabelSuccess?.label
} catch (e: java.lang.Exception) {
null
}
}

View file

@ -1,24 +1,24 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
suspend fun Networker.savedItemLabels(): List<SavedItemLabel> {
try {
val result = authenticatedApolloClient().query(GetLabelsQuery()).execute()
val labels = result.data?.labels?.onLabelsSuccess?.labels ?: listOf()
try {
val result = authenticatedApolloClient().query(GetLabelsQuery()).execute()
val labels = result.data?.labels?.onLabelsSuccess?.labels ?: listOf()
return labels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
labelDescription = it.labelFields.description
)
return labels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
labelDescription = it.labelFields.description
)
}
} catch (e: java.lang.Exception) {
return listOf()
}
} catch (e: java.lang.Exception) {
return listOf()
}
}

View file

@ -1,110 +1,117 @@
package app.omnivore.omnivore.core.network
import android.util.Log
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.graphql.generated.type.ContentReader
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.graphql.generated.type.ContentReader
import java.io.File
import java.net.URL
import java.nio.file.Files
import java.nio.file.StandardCopyOption
data class SavedItemQueryResponse(
val item: SavedItem?,
val highlights: List<Highlight>,
val labels: List<SavedItemLabel>,
val state: String
val item: SavedItem?,
val highlights: List<Highlight>,
val labels: List<SavedItemLabel>,
val state: String
) {
companion object {
fun emptyResponse(): SavedItemQueryResponse {
return SavedItemQueryResponse(null, listOf(), listOf(), state = "")
companion object {
fun emptyResponse(): SavedItemQueryResponse {
return SavedItemQueryResponse(null, listOf(), listOf(), state = "")
}
}
}
}
suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
try {
val result = authenticatedApolloClient().query(
GetArticleQuery(slug = slug)
).execute()
try {
val result = authenticatedApolloClient().query(
GetArticleQuery(slug = slug)
).execute()
val article = result.data?.article?.onArticleSuccess?.article
?: return SavedItemQueryResponse.emptyResponse()
val article = result.data?.article?.onArticleSuccess?.article
?: return SavedItemQueryResponse.emptyResponse()
val labels = article.labels ?: listOf()
val labels = article.labels ?: listOf()
val savedItemLabels = labels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
labelDescription = it.labelFields.description
)
}
val savedItemLabels = labels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
labelDescription = it.labelFields.description
)
}
val highlights = article.highlights.map {
val highlights = article.highlights.map {
// val updatedAtString = it.highlightFields.updatedAt as? String
Highlight(
highlightId = it.highlightFields.id,
type = it.highlightFields.type.toString(),
shortId = it.highlightFields.shortId,
quote = it.highlightFields.quote,
prefix = it.highlightFields.prefix,
suffix = it.highlightFields.suffix,
patch = it.highlightFields.patch,
annotation = it.highlightFields.annotation,
createdAt = it.highlightFields.createdAt as String?,
updatedAt = it.highlightFields.updatedAt as String?,
createdByMe = it.highlightFields.createdByMe,
color = it.highlightFields.color,
highlightPositionPercent = it.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = it.highlightFields.highlightPositionAnchorIndex
)
Highlight(
highlightId = it.highlightFields.id,
type = it.highlightFields.type.toString(),
shortId = it.highlightFields.shortId,
quote = it.highlightFields.quote,
prefix = it.highlightFields.prefix,
suffix = it.highlightFields.suffix,
patch = it.highlightFields.patch,
annotation = it.highlightFields.annotation,
createdAt = it.highlightFields.createdAt as String?,
updatedAt = it.highlightFields.updatedAt as String?,
createdByMe = it.highlightFields.createdByMe,
color = it.highlightFields.color,
highlightPositionPercent = it.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = it.highlightFields.highlightPositionAnchorIndex
)
}
var localPDFPath: String? = null
if (article.articleFields.contentReader == ContentReader.PDF) {
// download the PDF and save it locally
// article.articleFields.url
val localFile = File.createTempFile("pdf-" + article.articleFields.id, ".pdf")
val url = URL(article.articleFields.url)
Log.d("pdf", "creating local file: $localFile")
url.openStream()
.use { Files.copy(it, localFile.toPath(), StandardCopyOption.REPLACE_EXISTING) }
localPDFPath = localFile.toPath().toString()
}
val savedItem = SavedItem(
savedItemId = article.articleFields.id,
title = article.articleFields.title,
folder = article.articleFields.folder,
createdAt = article.articleFields.createdAt as String,
savedAt = article.articleFields.savedAt as String,
readAt = article.articleFields.readAt as String?,
updatedAt = article.articleFields.updatedAt as String?,
readingProgress = article.articleFields.readingProgressPercent,
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
imageURLString = article.articleFields.image,
pageURLString = article.articleFields.url,
descriptionText = article.articleFields.description,
publisherURLString = article.articleFields.originalArticleUrl,
siteName = article.articleFields.siteName,
author = article.articleFields.author,
publishDate = article.articleFields.publishedAt as String?,
slug = article.articleFields.slug,
isArchived = article.articleFields.isArchived,
contentReader = article.articleFields.contentReader.rawValue,
content = article.articleFields.content,
wordsCount = article.articleFields.wordsCount,
localPDFPath = localPDFPath
)
return SavedItemQueryResponse(
item = savedItem,
highlights,
labels = savedItemLabels,
state = article.articleFields.state?.rawValue ?: ""
)
} catch (e: java.lang.Exception) {
return SavedItemQueryResponse(item = null, listOf(), labels = listOf(), state = "")
}
var localPDFPath: String? = null
if (article.articleFields.contentReader == ContentReader.PDF) {
// download the PDF and save it locally
// article.articleFields.url
val localFile = File.createTempFile("pdf-" + article.articleFields.id, ".pdf", )
val url = URL(article.articleFields.url)
Log.d("pdf", "creating local file: $localFile")
url.openStream().use { Files.copy(it, localFile.toPath(), StandardCopyOption.REPLACE_EXISTING) }
localPDFPath = localFile.toPath().toString()
}
val savedItem = SavedItem(
savedItemId = article.articleFields.id,
title = article.articleFields.title,
createdAt = article.articleFields.createdAt as String,
savedAt = article.articleFields.savedAt as String,
readAt = article.articleFields.readAt as String?,
updatedAt = article.articleFields.updatedAt as String?,
readingProgress = article.articleFields.readingProgressPercent,
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
imageURLString = article.articleFields.image,
pageURLString = article.articleFields.url,
descriptionText = article.articleFields.description,
publisherURLString = article.articleFields.originalArticleUrl,
siteName = article.articleFields.siteName,
author = article.articleFields.author,
publishDate = article.articleFields.publishedAt as String?,
slug = article.articleFields.slug,
isArchived = article.articleFields.isArchived,
contentReader = article.articleFields.contentReader.rawValue,
content = article.articleFields.content,
wordsCount = article.articleFields.wordsCount,
localPDFPath = localPDFPath
)
return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels, state = article.articleFields.state?.rawValue ?: "")
} catch (e: java.lang.Exception) {
return SavedItemQueryResponse(item = null, listOf(), labels = listOf(), state = "")
}
}

View file

@ -1,100 +1,91 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import com.apollographql.apollo3.api.Optional
data class LibrarySearchQueryResponse(
val cursor: String?,
val items: List<LibrarySearchItem>
val cursor: String?, val items: List<LibrarySearchItem>
)
data class LibrarySearchItem(
val item: SavedItem,
val labels: List<SavedItemLabel>,
val highlights: List<Highlight>
val item: SavedItem, val labels: List<SavedItemLabel>, val highlights: List<Highlight>
)
suspend fun Networker.search(
cursor: String? = null,
limit: Int = 15,
query: String
cursor: String? = null, limit: Int = 15, query: String
): LibrarySearchQueryResponse {
try {
val result = authenticatedApolloClient().query(
SearchQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(limit),
query = Optional.presentIfNotNull(query)
)
).execute()
try {
val result = authenticatedApolloClient().query(
SearchQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(limit),
query = Optional.presentIfNotNull(query)
)
).execute()
val newCursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor
val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf()
val newCursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor
val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf()
val searchItems = itemList.map {
LibrarySearchItem(
item = SavedItem(
savedItemId = it.node.id,
title = it.node.title,
createdAt = it.node.createdAt as String,
savedAt = it.node.savedAt as String,
readAt = it.node.readAt as String?,
updatedAt = it.node.updatedAt as String?,
readingProgress = it.node.readingProgressPercent,
readingProgressAnchor = it.node.readingProgressAnchorIndex,
imageURLString = it.node.image,
pageURLString = it.node.url,
descriptionText = it.node.description,
publisherURLString = it.node.originalArticleUrl,
siteName = it.node.siteName,
author = it.node.author,
publishDate = it.node.publishedAt as String?,
slug = it.node.slug,
isArchived = it.node.isArchived,
contentReader = it.node.contentReader.rawValue,
content = it.node.content,
wordsCount = it.node.wordsCount,
),
labels = (it.node.labels ?: listOf()).map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = label.labelFields.createdAt as String?,
labelDescription = null
)
},
highlights = (it.node.highlights ?: listOf()).map { highlight ->
Highlight(
highlightId = highlight.highlightFields.id,
type = highlight.highlightFields.type.toString(),
annotation = highlight.highlightFields.annotation,
createdByMe = highlight.highlightFields.createdByMe,
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,
updatedAt = highlight.highlightFields.updatedAt as String?,
createdAt = highlight.highlightFields.createdAt as String?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex
)
val searchItems = itemList.map {
LibrarySearchItem(item = SavedItem(
savedItemId = it.node.id,
title = it.node.title,
folder = it.node.folder,
createdAt = it.node.createdAt as String,
savedAt = it.node.savedAt as String,
readAt = it.node.readAt as String?,
updatedAt = it.node.updatedAt as String?,
readingProgress = it.node.readingProgressPercent,
readingProgressAnchor = it.node.readingProgressAnchorIndex,
imageURLString = it.node.image,
pageURLString = it.node.url,
descriptionText = it.node.description,
publisherURLString = it.node.originalArticleUrl,
siteName = it.node.siteName,
author = it.node.author,
publishDate = it.node.publishedAt as String?,
slug = it.node.slug,
isArchived = it.node.isArchived,
contentReader = it.node.contentReader.rawValue,
content = it.node.content,
wordsCount = it.node.wordsCount
), labels = (it.node.labels ?: listOf()).map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = label.labelFields.createdAt as String?,
labelDescription = null
)
}, highlights = (it.node.highlights ?: listOf()).map { highlight ->
Highlight(
highlightId = highlight.highlightFields.id,
type = highlight.highlightFields.type.toString(),
annotation = highlight.highlightFields.annotation,
createdByMe = highlight.highlightFields.createdByMe,
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,
updatedAt = highlight.highlightFields.updatedAt as String?,
createdAt = highlight.highlightFields.createdAt as String?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex
)
})
}
)
}
return LibrarySearchQueryResponse(
cursor = newCursor,
items = searchItems
)
} catch (e: java.lang.Exception) {
return LibrarySearchQueryResponse(null, listOf())
}
return LibrarySearchQueryResponse(
cursor = newCursor, items = searchItems
)
} catch (e: java.lang.Exception) {
return LibrarySearchQueryResponse(null, listOf())
}
}

View file

@ -1,7 +1,12 @@
package app.omnivore.omnivore.di
import app.omnivore.omnivore.core.database.OmnivoreDatabase
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import app.omnivore.omnivore.core.database.dao.HighlightDao
import app.omnivore.omnivore.core.database.dao.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.dao.SavedItemLabelDao
import app.omnivore.omnivore.core.database.dao.SavedItemWithLabelsAndHighlightsDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -15,4 +20,29 @@ object DaosModule {
fun providesSavedItemDao(
database: OmnivoreDatabase,
): SavedItemDao = database.savedItemDao()
@Provides
fun providesSavedItemLabelDao(
database: OmnivoreDatabase,
): SavedItemLabelDao = database.savedItemLabelDao()
@Provides
fun providesHighlightDao(
database: OmnivoreDatabase,
): HighlightDao = database.highlightDao()
@Provides
fun providesHighlightChangesDao(
database: OmnivoreDatabase,
): HighlightChangesDao = database.highlightChangesDao()
@Provides
fun providesSavedItemWithLabelsAndHighlightsDao(
database: OmnivoreDatabase,
): SavedItemWithLabelsAndHighlightsDao = database.savedItemWithLabelsAndHighlightsDao()
@Provides
fun providesSavedItemAndSavedItemLabelCrossRefDao(
database: OmnivoreDatabase,
): SavedItemAndSavedItemLabelCrossRefDao = database.savedItemAndSavedItemLabelCrossRefDao()
}

View file

@ -5,11 +5,22 @@ import android.widget.Toast
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.*
import app.omnivore.omnivore.*
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.analytics.EventTracker
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.followingTabActive
import app.omnivore.omnivore.core.datastore.omnivoreAuthCookieString
import app.omnivore.omnivore.core.datastore.omnivoreAuthToken
import app.omnivore.omnivore.core.datastore.omnivorePendingUserToken
import app.omnivore.omnivore.core.datastore.omnivoreSelfHostedApiServer
import app.omnivore.omnivore.core.datastore.omnivoreSelfHostedWebServer
import app.omnivore.omnivore.core.network.AuthProviderLoginSubmit
import app.omnivore.omnivore.core.network.CreateAccountParams
import app.omnivore.omnivore.core.network.CreateAccountSubmit
@ -17,404 +28,422 @@ import app.omnivore.omnivore.core.network.CreateEmailAccountSubmit
import app.omnivore.omnivore.core.network.EmailLoginCredentials
import app.omnivore.omnivore.core.network.EmailLoginSubmit
import app.omnivore.omnivore.core.network.EmailSignUpParams
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.PendingUserSubmit
import app.omnivore.omnivore.core.network.RetrofitHelper
import app.omnivore.omnivore.core.network.SignInParams
import app.omnivore.omnivore.core.network.UserProfile
import app.omnivore.omnivore.core.network.viewer
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.graphql.generated.ValidateUsernameQuery
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.utils.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
import com.google.android.gms.common.api.ApiException
import com.google.android.gms.tasks.Task
import dagger.hilt.android.lifecycle.HiltViewModel
import io.intercom.android.sdk.Intercom
import kotlinx.coroutines.*
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.regex.Pattern
import javax.inject.Inject
enum class RegistrationState {
SocialLogin,
EmailSignIn,
EmailSignUp,
PendingUser,
SelfHosted
SocialLogin, EmailSignIn, EmailSignUp, PendingUser, SelfHosted
}
data class PendingEmailUserCreds(
val email: String,
val password: String
val email: String, val password: String
)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val eventTracker: EventTracker,
private val networker: Networker,
private val dataService: DataService,
private val resourceProvider: ResourceProvider
): ViewModel() {
private var validateUsernameJob: Job? = null
private val datastoreRepository: DatastoreRepository,
private val eventTracker: EventTracker,
private val networker: Networker,
private val dataService: DataService,
private val resourceProvider: ResourceProvider
) : ViewModel() {
private var validateUsernameJob: Job? = null
var isLoading by mutableStateOf(false)
private set
var isLoading by mutableStateOf(false)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
var hasValidUsername by mutableStateOf<Boolean>(false)
private set
var hasValidUsername by mutableStateOf<Boolean>(false)
private set
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
private set
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
private set
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
private set
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
private set
val hasAuthTokenLiveData: LiveData<Boolean> = datastoreRepo
.hasAuthTokenFlow
.distinctUntilChanged()
.asLiveData()
val hasAuthTokenLiveData: LiveData<Boolean> =
datastoreRepository.hasAuthTokenFlow.distinctUntilChanged().asLiveData()
val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin)
val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin)
fun getAuthCookieString(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthCookieString)
}
val followingTabActiveState: StateFlow<Boolean> = datastoreRepository.getBoolean(
followingTabActive
).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false
)
fun setSelfHostingDetails(context: Context, apiServer: String, webServer: String) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.omnivoreSelfHostedAPIServer, apiServer)
datastoreRepo.putString(DatastoreKeys.omnivoreSelfHostedWebServer, webServer)
Toast.makeText(
context,
context.getString(R.string.login_view_model_self_hosting_settings_updated),
Toast.LENGTH_SHORT
).show()
}
}
fun resetSelfHostingDetails(context: Context) {
viewModelScope.launch {
datastoreRepo.clearValue(DatastoreKeys.omnivoreSelfHostedAPIServer)
datastoreRepo.clearValue(DatastoreKeys.omnivoreSelfHostedWebServer)
Toast.makeText(
context,
context.getString(R.string.login_view_model_self_hosting_settings_reset),
Toast.LENGTH_SHORT
).show()
fun setSelfHostingDetails(context: Context, apiServer: String, webServer: String) {
viewModelScope.launch {
datastoreRepository.putString(omnivoreSelfHostedApiServer, apiServer)
datastoreRepository.putString(omnivoreSelfHostedWebServer, webServer)
Toast.makeText(
context,
context.getString(R.string.login_view_model_self_hosting_settings_updated),
Toast.LENGTH_SHORT
).show()
}
}
fun resetSelfHostingDetails(context: Context) {
viewModelScope.launch {
datastoreRepository.clearValue(omnivoreSelfHostedApiServer)
datastoreRepository.clearValue(omnivoreSelfHostedWebServer)
Toast.makeText(
context,
context.getString(R.string.login_view_model_self_hosting_settings_reset),
Toast.LENGTH_SHORT
).show()
}
}
fun showSocialLogin() {
resetState()
registrationStateLiveData.value = RegistrationState.SocialLogin
}
fun showEmailSignIn() {
resetState()
registrationStateLiveData.value = RegistrationState.EmailSignIn
}
fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) {
resetState()
pendingEmailUserCreds = pendingCreds
registrationStateLiveData.value = RegistrationState.EmailSignUp
}
fun showSelfHostedSettings() {
resetState()
registrationStateLiveData.value = RegistrationState.SelfHosted
}
fun cancelNewUserSignUp() {
resetState()
viewModelScope.launch {
datastoreRepo.clearValue(DatastoreKeys.omnivorePendingUserToken)
}
showSocialLogin()
}
fun registerUser() {
viewModelScope.launch {
val viewer = networker.viewer()
viewer?.let {
eventTracker.registerUser(viewer.userID, viewer.intercomHash, BuildConfig.DEBUG)
}
fun showSocialLogin() {
resetState()
registrationStateLiveData.value = RegistrationState.SocialLogin
}
}
private fun resetState() {
validateUsernameJob = null
isLoading = false
errorMessage = null
hasValidUsername = false
usernameValidationErrorMessage = null
pendingEmailUserCreds = null
}
fun showEmailSignIn() {
resetState()
registrationStateLiveData.value = RegistrationState.EmailSignIn
}
fun validateUsername(potentialUsername: String) {
validateUsernameJob?.cancel()
fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) {
resetState()
pendingEmailUserCreds = pendingCreds
registrationStateLiveData.value = RegistrationState.EmailSignUp
}
validateUsernameJob = viewModelScope.launch {
delay(2000)
fun showSelfHostedSettings() {
resetState()
registrationStateLiveData.value = RegistrationState.SelfHosted
}
// Check the username requirements first
if (potentialUsername.isEmpty()) {
fun cancelNewUserSignUp() {
resetState()
viewModelScope.launch {
datastoreRepository.clearValue(omnivorePendingUserToken)
}
showSocialLogin()
}
fun registerUser() {
viewModelScope.launch {
val viewer = networker.viewer()
viewer?.let {
eventTracker.registerUser(viewer.userID, viewer.intercomHash, BuildConfig.DEBUG)
}
}
}
private fun resetState() {
validateUsernameJob = null
isLoading = false
errorMessage = null
hasValidUsername = false
usernameValidationErrorMessage = null
hasValidUsername = false
return@launch
}
pendingEmailUserCreds = null
}
if (potentialUsername.length < 4 || potentialUsername.length > 15) {
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_validation_length_error_msg)
hasValidUsername = false
return@launch
}
fun validateUsername(potentialUsername: String) {
validateUsernameJob?.cancel()
val isValidPattern = Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$")
.matcher(potentialUsername)
.matches()
validateUsernameJob = viewModelScope.launch {
delay(2000)
if (!isValidPattern) {
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_validation_alphanumeric_error_msg)
hasValidUsername = false
return@launch
}
// Check the username requirements first
if (potentialUsername.isEmpty()) {
usernameValidationErrorMessage = null
hasValidUsername = false
return@launch
}
val apolloClient = ApolloClient.Builder()
.serverUrl("${Constants.apiURL}/api/graphql")
.build()
if (potentialUsername.length < 4 || potentialUsername.length > 15) {
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_validation_length_error_msg
)
hasValidUsername = false
return@launch
}
try {
val response = apolloClient.query(
ValidateUsernameQuery(username = potentialUsername)
).execute()
val isValidPattern =
Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$").matcher(potentialUsername).matches()
if (response.data?.validateUsername == true) {
usernameValidationErrorMessage = null
hasValidUsername = true
if (!isValidPattern) {
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_validation_alphanumeric_error_msg
)
hasValidUsername = false
return@launch
}
val apolloClient =
ApolloClient.Builder().serverUrl("${Constants.apiURL}/api/graphql").build()
try {
val response = apolloClient.query(
ValidateUsernameQuery(username = potentialUsername)
).execute()
if (response.data?.validateUsername == true) {
usernameValidationErrorMessage = null
hasValidUsername = true
} else {
hasValidUsername = false
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_not_available_error_msg
)
}
} catch (e: java.lang.Exception) {
hasValidUsername = false
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_connection_error_msg
)
}
}
}
fun login(email: String, password: String) {
viewModelScope.launch {
val emailLogin =
RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java)
isLoading = true
errorMessage = null
val result = emailLogin.submitEmailLogin(
EmailLoginCredentials(email = email, password = password)
)
isLoading = false
if (result.body()?.pendingEmailVerification == true) {
showEmailSignUp(
pendingCreds = PendingEmailUserCreds(
email = email, password = password
)
)
return@launch
}
if (result.body()?.authToken != null) {
datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!)
} else {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_error_msg
)
}
if (result.body()?.authCookieString != null) {
datastoreRepository.putString(
omnivoreAuthCookieString, result.body()?.authCookieString!!
)
}
}
}
fun submitEmailSignUp(
email: String,
password: String,
username: String,
name: String,
) {
viewModelScope.launch {
val request =
RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java)
isLoading = true
errorMessage = null
val params = EmailSignUpParams(
email = email, password = password, name = name, username = username
)
val result = request.submitCreateEmailAccount(params)
isLoading = false
if (result.errorBody() != null) {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg
)
} else {
pendingEmailUserCreds = PendingEmailUserCreds(email, password)
}
}
}
private fun getPendingAuthToken(): String? = runBlocking {
datastoreRepository.getString(omnivorePendingUserToken)
}
fun submitProfile(username: String, name: String) {
viewModelScope.launch {
val request =
RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java)
isLoading = true
errorMessage = null
val pendingUserToken = getPendingAuthToken() ?: ""
val userProfile = UserProfile(name = name, username = username)
val params = CreateAccountParams(
pendingUserToken = pendingUserToken, userProfile = userProfile
)
val result = request.submitCreateAccount(params)
isLoading = false
if (result.body()?.authToken != null) {
datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!)
} else {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_error_msg
)
}
if (result.body()?.authCookieString != null) {
datastoreRepository.putString(
omnivoreAuthCookieString, result.body()?.authCookieString!!
)
}
}
}
fun handleAppleToken(authToken: String) {
submitAuthProviderPayload(
params = SignInParams(token = authToken, provider = "APPLE")
)
}
fun logout() {
viewModelScope.launch {
datastoreRepository.clear()
dataService.clearDatabase()
Intercom.client().logout()
eventTracker.logout()
}
}
fun resetErrorMessage() {
errorMessage = null
}
fun showGoogleErrorMessage() {
errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg)
}
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
val result = task.getResult(ApiException::class.java)
val googleIdToken = result?.idToken ?: ""
// If token is missing then set the error message
if (googleIdToken.isEmpty()) {
errorMessage = resourceProvider.getString(
R.string.login_view_model_missing_auth_token_error_msg
)
return
}
submitAuthProviderPayload(
params = SignInParams(token = googleIdToken, provider = "GOOGLE")
)
}
private fun submitAuthProviderPayload(params: SignInParams) {
viewModelScope.launch {
val login =
RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java)
isLoading = true
errorMessage = null
val result = login.submitAuthProviderLogin(params)
isLoading = false
if (result.body()?.authToken != null) {
datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!)
if (result.body()?.authCookieString != null) {
datastoreRepository.putString(
omnivoreAuthCookieString, result.body()?.authCookieString!!
)
}
} else {
when (result.code()) {
401, 403 -> {
// This is a new user so they should go through the new user flow
submitAuthProviderPayloadForPendingToken(params = params)
}
418 -> {
// Show pending email state
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg
)
}
else -> {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg
)
}
}
}
}
}
private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) {
isLoading = true
errorMessage = null
val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java)
val result = request.submitPendingUser(params)
isLoading = false
if (result.body()?.pendingUserToken != null) {
datastoreRepository.putString(
omnivorePendingUserToken, result.body()?.pendingUserToken!!
)
registrationStateLiveData.value = RegistrationState.PendingUser
} else {
hasValidUsername = false
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_username_not_available_error_msg)
}
} catch (e: java.lang.Exception) {
hasValidUsername = false
usernameValidationErrorMessage = resourceProvider.getString(
R.string.login_view_model_connection_error_msg)
}
}
}
fun login(email: String, password: String) {
viewModelScope.launch {
val emailLogin = RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java)
isLoading = true
errorMessage = null
val result = emailLogin.submitEmailLogin(
EmailLoginCredentials(email = email, password = password)
)
isLoading = false
if (result.body()?.pendingEmailVerification == true) {
showEmailSignUp(pendingCreds = PendingEmailUserCreds(email = email, password = password))
return@launch
}
if (result.body()?.authToken != null) {
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
} else {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_error_msg)
}
if (result.body()?.authCookieString != null) {
datastoreRepo.putString(
DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!!
)
}
}
}
fun submitEmailSignUp(
email: String,
password: String,
username: String,
name: String,
) {
viewModelScope.launch {
val request = RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java)
isLoading = true
errorMessage = null
val params = EmailSignUpParams(
email = email,
password = password,
name = name,
username = username
)
val result = request.submitCreateEmailAccount(params)
isLoading = false
if (result.errorBody() != null) {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
} else {
pendingEmailUserCreds = PendingEmailUserCreds(email, password)
}
}
}
private fun getPendingAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivorePendingUserToken)
}
fun submitProfile(username: String, name: String) {
viewModelScope.launch {
val request = RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java)
isLoading = true
errorMessage = null
val pendingUserToken = getPendingAuthToken() ?: ""
val userProfile = UserProfile(name = name, username = username)
val params = CreateAccountParams(
pendingUserToken = pendingUserToken,
userProfile = userProfile
)
val result = request.submitCreateAccount(params)
isLoading = false
if (result.body()?.authToken != null) {
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
} else {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_error_msg)
}
if (result.body()?.authCookieString != null) {
datastoreRepo.putString(
DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!!
)
}
}
}
fun handleAppleToken(authToken: String) {
submitAuthProviderPayload(
params = SignInParams(token = authToken, provider = "APPLE")
)
}
fun logout() {
viewModelScope.launch {
datastoreRepo.clear()
dataService.clearDatabase()
Intercom.client().logout()
eventTracker.logout()
}
}
fun resetErrorMessage() {
errorMessage = null
}
fun showGoogleErrorMessage() {
errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg)
}
fun handleGoogleAuthTask(task: Task<GoogleSignInAccount>) {
val result = task.getResult(ApiException::class.java)
val googleIdToken = result?.idToken ?: ""
// If token is missing then set the error message
if (googleIdToken.isEmpty()) {
errorMessage = resourceProvider.getString(
R.string.login_view_model_missing_auth_token_error_msg)
return
}
submitAuthProviderPayload(
params = SignInParams(token = googleIdToken, provider = "GOOGLE")
)
}
private fun submitAuthProviderPayload(params: SignInParams) {
viewModelScope.launch {
val login = RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java)
isLoading = true
errorMessage = null
val result = login.submitAuthProviderLogin(params)
isLoading = false
if (result.body()?.authToken != null) {
datastoreRepo.putString(DatastoreKeys.omnivoreAuthToken, result.body()?.authToken!!)
if (result.body()?.authCookieString != null) {
datastoreRepo.putString(
DatastoreKeys.omnivoreAuthCookieString, result.body()?.authCookieString!!
)
}
} else {
when (result.code()) {
401, 403 -> {
// This is a new user so they should go through the new user flow
submitAuthProviderPayloadForPendingToken(params = params)
}
418 -> {
// Show pending email state
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
}
else -> {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
}
R.string.login_view_model_something_went_wrong_two_error_msg
)
}
}
}
}
private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) {
isLoading = true
errorMessage = null
val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java)
val result = request.submitPendingUser(params)
isLoading = false
if (result.body()?.pendingUserToken != null) {
datastoreRepo.putString(
DatastoreKeys.omnivorePendingUserToken, result.body()?.pendingUserToken!!
)
registrationStateLiveData.value = RegistrationState.PendingUser
} else {
errorMessage = resourceProvider.getString(
R.string.login_view_model_something_went_wrong_two_error_msg)
}
}
}

View file

@ -6,14 +6,14 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.omnivoreAuthToken
import app.omnivore.omnivore.graphql.generated.UpdatePageMutation
import app.omnivore.omnivore.graphql.generated.type.UpdatePageInput
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
@ -45,7 +45,7 @@ class EditInfoViewModel @Inject constructor(
private set
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
datastoreRepo.getString(omnivoreAuthToken)
}
fun editInfo(itemId: String, title: String, author: String?, description: String?) {

View file

@ -0,0 +1,160 @@
package app.omnivore.omnivore.feature.following
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.library.AddLinkBottomSheet
import app.omnivore.omnivore.feature.library.EditBottomSheet
import app.omnivore.omnivore.feature.library.LabelBottomSheet
import app.omnivore.omnivore.feature.library.LibraryBottomSheetState
import app.omnivore.omnivore.feature.library.LibraryNavigationBar
import app.omnivore.omnivore.feature.library.LibraryViewContent
import app.omnivore.omnivore.feature.library.SavedItemSortFilter
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.navigation.TopLevelDestination
import kotlinx.coroutines.launch
@Composable
internal fun FollowingScreen(
navController: NavHostController,
labelsViewModel: LabelsViewModel = hiltViewModel(),
saveViewModel: SaveViewModel = hiltViewModel(),
editInfoViewModel: EditInfoViewModel = hiltViewModel(),
viewModel: FollowingViewModel = hiltViewModel()
) {
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
viewModel.snackbarMessage?.let {
coroutineScope.launch {
snackbarHostState.showSnackbar(it)
viewModel.clearSnackbarMessage()
}
}
val labels by viewModel.labelsState.collectAsStateWithLifecycle()
val currentTopLevelDestination =
TopLevelDestination.entries.find { it.route == navController.currentDestination?.route }
val selectedItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState()
val savedItemFilter by viewModel.appliedFilterState.collectAsStateWithLifecycle()
val activeLabels by viewModel.activeLabels.collectAsStateWithLifecycle()
val sortFilter: SavedItemSortFilter by viewModel.appliedSortFilterLiveData.collectAsStateWithLifecycle()
val bottomSheetState: LibraryBottomSheetState by viewModel.bottomSheetState.collectAsStateWithLifecycle()
when (bottomSheetState) {
LibraryBottomSheetState.ADD_LINK -> {
AddLinkBottomSheet(saveViewModel) {
viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
}
LibraryBottomSheetState.LABEL -> {
LabelBottomSheet(
deleteCurrentItem = { viewModel.currentItem.value = null },
labels = labels,
currentSavedItemData = viewModel.currentSavedItemUnderEdit(),
labelsViewModel,
{ viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN },
{ labelName, hexColorValue ->
viewModel.createNewSavedItemLabel(labelName, hexColorValue)
},
{ savedItemId, labels ->
viewModel.updateSavedItemLabels(savedItemId, labels)
},
activeLabels,
{ viewModel.updateAppliedLabels(it) }
)
}
LibraryBottomSheetState.EDIT -> {
EditBottomSheet(
editInfoViewModel,
deleteCurrentItem = { viewModel.currentItem.value = null },
{ viewModel.refresh() },
viewModel.currentSavedItemUnderEdit()
) {
viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
}
LibraryBottomSheetState.HIDDEN -> {
}
}
Scaffold(
topBar = {
LibraryNavigationBar(
currentDestination = currentTopLevelDestination,
savedItemViewModel = viewModel,
onSearchClicked = { navController.navigate(Routes.Search.route) },
onAddLinkClicked = {
viewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
}
)
},
) { paddingValues ->
when (uiState) {
is FollowingUiState.Success -> {
LibraryViewContent(
itemsFilter = savedItemFilter,
activeLabels = activeLabels,
sortFilter = sortFilter,
updateSavedItemFilter = { viewModel.updateSavedItemFilter(it) },
updateSavedItemSortFilter = { viewModel.updateSavedItemSortFilter(it) },
setBottomSheetState = { viewModel.setBottomSheetState(it) },
updateAppliedLabels = { viewModel.updateAppliedLabels(it) },
isFollowingScreen = currentTopLevelDestination == TopLevelDestination.FOLLOWING,
{ viewModel.actionsMenuItemLiveData.postValue(null) },
savedItemViewModel = viewModel,
refresh = { viewModel.refresh() },
onUnarchive = { viewModel.unarchiveSavedItem(it) },
onArchive = { viewModel.archiveSavedItem(it) },
onDelete = { viewModel.deleteSavedItem(it) },
paddingValues = paddingValues,
items = (uiState as FollowingUiState.Success).items,
selectedItem = selectedItem,
onSavedItemAction = { id, action ->
viewModel.handleSavedItemAction(id, action)
},
{ viewModel.loadUsingSearchAPI() },
{ viewModel.initialLoad() }
)
}
is FollowingUiState.Loading -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(strokeCap = StrokeCap.Round)
}
}
else -> {
// TODO
}
}
}
}

View file

@ -0,0 +1,394 @@
package app.omnivore.omnivore.feature.following
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.lastUsedSavedItemFilter
import app.omnivore.omnivore.core.datastore.lastUsedSavedItemSortFilter
import app.omnivore.omnivore.core.datastore.libraryLastSyncTimestamp
import app.omnivore.omnivore.feature.library.LibraryBottomSheetState
import app.omnivore.omnivore.feature.library.SavedItemAction
import app.omnivore.omnivore.feature.library.SavedItemFilter
import app.omnivore.omnivore.feature.library.SavedItemSortFilter
import app.omnivore.omnivore.feature.library.SavedItemViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import java.time.Instant
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class FollowingViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val libraryRepository: LibraryRepository,
@ApplicationContext private val applicationContext: Context
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
private var librarySearchCursor: String? = null
var snackbarMessage by mutableStateOf<String?>(null)
private set
private val _libraryQuery = MutableStateFlow(
LibraryQuery(
folders = listOf("following"),
allowedArchiveStates = listOf(0),
sortKey = "newest",
requiredLabels = listOf(),
excludedLabels = listOf(),
allowedContentReaders = listOf("WEB", "PDF", "EPUB")
)
)
val uiState: StateFlow<FollowingUiState> = _libraryQuery.flatMapLatest { query ->
libraryRepository.getSavedItems(query)
}.map(FollowingUiState::Success).stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = FollowingUiState.Loading
)
val appliedFilterState = MutableStateFlow(SavedItemFilter.FOLLOWING)
val appliedSortFilterLiveData = MutableStateFlow(SavedItemSortFilter.NEWEST)
val bottomSheetState = MutableStateFlow(LibraryBottomSheetState.HIDDEN)
val currentItem = mutableStateOf<String?>(null)
val labelsState = libraryRepository.getSavedItemsLabels().stateIn(
scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf()
)
val activeLabels = MutableStateFlow<List<SavedItemLabel>>(listOf())
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
private fun loadInitialFilterValues() {
syncLabels()
viewModelScope.launch {
handleFilterChanges()
for (slug in contentRequestChannel) {
libraryRepository.fetchSavedItemContent(slug)
}
}
updateSavedItemFilter(appliedFilterState.value)
}
private fun syncLabels() {
viewModelScope.launch {
val labels = libraryRepository.getLabels()
libraryRepository.insertAllLabels(labels)
}
}
fun clearSnackbarMessage() {
snackbarMessage = null
}
fun refresh() {
librarySearchCursor = null
load()
}
fun setBottomSheetState(state: LibraryBottomSheetState) {
bottomSheetState.value = state
}
private fun getLastSyncTime(): Instant? = runBlocking {
datastoreRepo.getString(libraryLastSyncTimestamp)?.let {
try {
return@let Instant.parse(it)
} catch (e: Exception) {
return@let null
}
}
}
fun initialLoad() {
if (getLastSyncTime() == null) {
librarySearchCursor = null
}
load()
}
fun load() {
loadInitialFilterValues()
viewModelScope.launch {
syncItems()
loadUsingSearchAPI()
}
}
fun loadUsingSearchAPI() {
viewModelScope.launch {
val result = libraryRepository.librarySearch(
cursor = librarySearchCursor,
query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
result.savedItems.map {
val isSavedInDB = libraryRepository.isSavedItemContentStoredInDB(it.savedItem.slug)
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
}
}
}
fun updateSavedItemFilter(filter: SavedItemFilter) {
viewModelScope.launch {
datastoreRepo.putString(lastUsedSavedItemFilter, filter.rawValue)
appliedFilterState.value = filter
handleFilterChanges()
}
}
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
viewModelScope.launch {
datastoreRepo.putString(lastUsedSavedItemSortFilter, filter.rawValue)
appliedSortFilterLiveData.value = filter
handleFilterChanges()
}
}
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
viewModelScope.launch {
activeLabels.value = labels
handleFilterChanges()
}
}
private fun handleFilterChanges() {
librarySearchCursor = null
val sortKey = when (appliedSortFilterLiveData.value) {
SavedItemSortFilter.NEWEST -> "newest"
SavedItemSortFilter.OLDEST -> "oldest"
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
}
val allowedArchiveStates = when (appliedFilterState.value) {
SavedItemFilter.ALL -> listOf(0, 1)
SavedItemFilter.ARCHIVED -> listOf(1)
else -> listOf(0)
}
val allowedContentReaders = when (appliedFilterState.value) {
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
else -> listOf("WEB", "PDF", "EPUB")
}
var requiredLabels = when (appliedFilterState.value) {
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
SavedItemFilter.FEEDS -> listOf("RSS")
else -> activeLabels.value.map { it.name }
}
activeLabels.value.let { it ->
requiredLabels = requiredLabels + it.map { it.name }
}
val excludeLabels = when (appliedFilterState.value) {
SavedItemFilter.NON_FEED -> listOf("Newsletter", "RSS")
else -> listOf()
}
_libraryQuery.value = LibraryQuery(
folders = listOf("following"),
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
requiredLabels = requiredLabels,
excludedLabels = excludeLabels,
allowedContentReaders = allowedContentReaders
)
}
private suspend fun syncItems() {
val syncStart = Instant.now()
val lastSyncDate = getLastSyncTime() ?: Instant.MIN
withContext(Dispatchers.IO) {
performItemSync(
cursor = null,
since = lastSyncDate.toString(),
count = 0,
startTime = syncStart.toString()
)
}
}
private suspend fun performItemSync(
cursor: String?,
since: String,
count: Int,
startTime: String,
isInitialBatch: Boolean = true
) {
libraryRepository.syncOfflineItemsWithServerIfNeeded()
val result = libraryRepository.sync(since = since, cursor = cursor, limit = 20)
// Fetch content for the initial batch only
if (isInitialBatch) {
for (slug in result.savedItemSlugs) {
delay(250)
contentRequestChannel.send(slug)
}
}
val totalCount = count + result.count
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
performItemSync(
cursor = result.cursor,
since = since,
count = totalCount,
startTime = startTime,
isInitialBatch = false
)
} else {
datastoreRepo.putString(libraryLastSyncTimestamp, startTime)
}
}
override fun handleSavedItemAction(itemId: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
deleteSavedItem(itemId)
}
SavedItemAction.Archive -> {
archiveSavedItem(itemId)
}
SavedItemAction.Unarchive -> {
unarchiveSavedItem(itemId)
}
SavedItemAction.EditLabels -> {
currentItem.value = itemId
bottomSheetState.value = LibraryBottomSheetState.LABEL
}
SavedItemAction.EditInfo -> {
currentItem.value = itemId
bottomSheetState.value = LibraryBottomSheetState.EDIT
}
SavedItemAction.MarkRead -> {
viewModelScope.launch {
libraryRepository.updateReadingProgress(itemId, 100.0, 0)
}
}
SavedItemAction.MarkUnread -> {
viewModelScope.launch {
libraryRepository.updateReadingProgress(itemId, 0.0, 0)
}
}
}
actionsMenuItemLiveData.postValue(null)
}
fun deleteSavedItem(itemID: String) {
viewModelScope.launch {
libraryRepository.deleteSavedItem(itemID)
}
}
fun archiveSavedItem(itemID: String) {
viewModelScope.launch {
libraryRepository.archiveSavedItem(itemID)
}
}
fun unarchiveSavedItem(itemID: String) {
viewModelScope.launch {
libraryRepository.unarchiveSavedItem(itemID)
}
}
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
viewModelScope.launch {
val result = libraryRepository.setSavedItemLabels(
itemId = savedItemID, labels = labels
)
snackbarMessage = if (result) {
applicationContext.getString(R.string.library_view_model_snackbar_success)
} else {
applicationContext.getString(R.string.library_view_model_snackbar_error)
}
handleFilterChanges()
}
}
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
viewModelScope.launch {
libraryRepository.createNewSavedItemLabel(labelName, hexColorValue)
}
}
fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? {
currentItem.value?.let { itemID ->
return (uiState.value as FollowingUiState.Success).items.first { it.savedItem.savedItemId == itemID }
}
return null
}
private fun searchQueryString(): String {
var query =
"${appliedFilterState.value.queryString} ${appliedSortFilterLiveData.value.queryString}"
activeLabels.value.let {
if (it.isNotEmpty()) {
query += " label:"
query += it.joinToString { label -> label.name }
}
}
return query
}
}
sealed interface FollowingUiState {
data object Loading : FollowingUiState
data class Success(
val items: List<SavedItemWithLabelsAndHighlights>,
) : FollowingUiState
data object Error : FollowingUiState
}

View file

@ -16,7 +16,6 @@ import androidx.compose.material3.SuggestionChipDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@ -26,25 +25,26 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.toLowerCase
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.feature.components.LabelChipColors
@Composable
fun LibraryFilterBar(
viewModel: LibraryViewModel = hiltViewModel()
isFollowingScreen: Boolean,
itemsFilter: SavedItemFilter,
sortFilter: SavedItemSortFilter,
activeLabels: List<SavedItemLabel>,
setBottomSheetState: (LibraryBottomSheetState) -> Unit,
updateSavedItemFilter: (SavedItemFilter) -> Unit,
updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit,
updateAppliedLabels: (List<SavedItemLabel>) -> Unit
) {
var isSavedItemFilterMenuExpanded by remember { mutableStateOf(false) }
val activeSavedItemFilter: SavedItemFilter by viewModel.appliedFilterLiveData.observeAsState(
SavedItemFilter.INBOX
)
val activeLabels: List<SavedItemLabel> by viewModel.activeLabelsLiveData.observeAsState(listOf())
var isSavedItemSortFilterMenuExpanded by remember { mutableStateOf(false) }
val activeSavedItemSortFilter: SavedItemSortFilter by viewModel.appliedSortFilterLiveData.observeAsState(
SavedItemSortFilter.NEWEST
)
val listState = rememberLazyListState()
Column {
@ -58,7 +58,9 @@ fun LibraryFilterBar(
) {
item {
AssistChip(onClick = { isSavedItemFilterMenuExpanded = true },
label = { Text(activeSavedItemFilter.displayText) },
label = { Text(
itemsFilter.displayText
) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
@ -68,7 +70,7 @@ fun LibraryFilterBar(
modifier = Modifier.padding(end = 6.dp)
)
AssistChip(onClick = { isSavedItemSortFilterMenuExpanded = true },
label = { Text(activeSavedItemSortFilter.displayText) },
label = { Text(sortFilter.displayText) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
@ -78,7 +80,7 @@ fun LibraryFilterBar(
modifier = Modifier.padding(end = 6.dp)
)
AssistChip(
onClick = { viewModel.bottomSheetState.value = LibraryBottomSheetState.LABEL },
onClick = { setBottomSheetState(LibraryBottomSheetState.LABEL) },
label = { Text(stringResource(R.string.library_filter_bar_label_labels)) },
trailingIcon = {
Icon(
@ -92,10 +94,12 @@ fun LibraryFilterBar(
items(activeLabels.sortedWith(compareBy { it.name.toLowerCase(Locale.current) })) { label ->
val chipColors = LabelChipColors.fromHex(label.color)
AssistChip(onClick = {
viewModel.updateAppliedLabels((viewModel.activeLabelsLiveData.value
?: listOf()).filter { it.savedItemLabelId != label.savedItemLabelId })
},
AssistChip(
onClick = {
updateAppliedLabels(
activeLabels.filter { it.savedItemLabelId != label.savedItemLabelId }
)
},
label = { Text(label.name) },
border = null,
colors = SuggestionChipDefaults.elevatedSuggestionChipColors(
@ -113,12 +117,15 @@ fun LibraryFilterBar(
}
}
SavedItemFilterContextMenu(isExpanded = isSavedItemFilterMenuExpanded,
SavedItemFilterContextMenu(
isFollowingScreen = isFollowingScreen,
isExpanded = isSavedItemFilterMenuExpanded,
onDismiss = { isSavedItemFilterMenuExpanded = false },
actionHandler = { viewModel.updateSavedItemFilter(it) })
actionHandler = { updateSavedItemFilter(it) }
)
SavedItemSortFilterContextMenu(isExpanded = isSavedItemSortFilterMenuExpanded,
onDismiss = { isSavedItemSortFilterMenuExpanded = false },
actionHandler = { viewModel.updateSavedItemSortFilter(it) })
actionHandler = { updateSavedItemSortFilter(it) })
}
}

View file

@ -39,14 +39,15 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.navigation.TopLevelDestination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LibraryNavigationBar(
currentDestination: TopLevelDestination?,
savedItemViewModel: SavedItemViewModel,
onSearchClicked: () -> Unit,
onAddLinkClicked: () -> Unit,
onSettingsIconClick: () -> Unit
onAddLinkClicked: () -> Unit
) {
val actionsMenuItem: SavedItemWithLabelsAndHighlights? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(
null
@ -57,9 +58,11 @@ fun LibraryNavigationBar(
TopAppBar(
title = {
Text(
if (actionsMenuItem == null)
stringResource(R.string.library_nav_bar_title) else
if (actionsMenuItem == null) {
currentDestination?.titleTextId?.let { stringResource(it) } ?: ""
} else {
stringResource(R.string.library_nav_bar_title_alt)
}
)
},
colors = TopAppBarDefaults.topAppBarColors(
@ -159,13 +162,6 @@ fun LibraryNavigationBar(
contentDescription = null
)
}
IconButton(onClick = onSettingsIconClick) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
}
}
)

View file

@ -21,21 +21,20 @@ import androidx.compose.material.DismissState
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.Icon
import androidx.compose.material.ScaffoldState
import androidx.compose.material.SwipeToDismiss
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Archive
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Unarchive
import androidx.compose.material.rememberDismissState
import androidx.compose.material.rememberScaffoldState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberModalBottomSheetState
@ -73,36 +72,41 @@ import app.omnivore.omnivore.feature.save.SaveState
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.savedItemViews.SavedItemCard
import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.navigation.TopLevelDestination
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@Composable
internal fun LibraryView(
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel,
navController: NavHostController,
labelsViewModel: LabelsViewModel = hiltViewModel(),
saveViewModel: SaveViewModel = hiltViewModel(),
editInfoViewModel: EditInfoViewModel = hiltViewModel(),
viewModel: LibraryViewModel = hiltViewModel()
) {
val scaffoldState: ScaffoldState = rememberScaffoldState()
val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val showBottomSheet: LibraryBottomSheetState by viewModel.bottomSheetState.observeAsState(
LibraryBottomSheetState.HIDDEN
)
viewModel.snackbarMessage?.let {
coroutineScope.launch {
scaffoldState.snackbarHostState.showSnackbar(it)
snackbarHostState.showSnackbar(it)
viewModel.clearSnackbarMessage()
}
}
when (showBottomSheet) {
val labels by viewModel.labelsState.collectAsStateWithLifecycle()
val currentTopLevelDestination =
TopLevelDestination.entries.find { it.route == navController.currentDestination?.route }
val selectedItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState()
val savedItemFilter by viewModel.appliedFilterState.collectAsStateWithLifecycle()
val activeLabels by viewModel.activeLabels.collectAsStateWithLifecycle()
val sortFilter: SavedItemSortFilter by viewModel.appliedSortFilterLiveData.collectAsStateWithLifecycle()
val bottomSheetState: LibraryBottomSheetState by viewModel.bottomSheetState.collectAsStateWithLifecycle()
when (bottomSheetState) {
LibraryBottomSheetState.ADD_LINK -> {
AddLinkBottomSheet(saveViewModel) {
viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
@ -110,18 +114,26 @@ internal fun LibraryView(
}
LibraryBottomSheetState.LABEL -> {
LabelBottomSheet(
viewModel,
labelsViewModel
) {
viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
LabelBottomSheet(deleteCurrentItem = { viewModel.currentItem.value = null },
labels = labels,
currentSavedItemData = viewModel.currentSavedItemUnderEdit(),
labelsViewModel,
{ viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN },
{ labelName, hexColorValue ->
viewModel.createNewSavedItemLabel(labelName, hexColorValue)
},
{ savedItemId, labels ->
viewModel.updateSavedItemLabels(savedItemId, labels)
},
activeLabels,
{ viewModel.updateAppliedLabels(it) })
}
LibraryBottomSheetState.EDIT -> {
EditBottomSheet(
editInfoViewModel,
viewModel
EditBottomSheet(editInfoViewModel,
deleteCurrentItem = { viewModel.currentItem.value = null },
{ viewModel.refresh() },
viewModel.currentSavedItemUnderEdit()
) {
viewModel.bottomSheetState.value = LibraryBottomSheetState.HIDDEN
}
@ -133,22 +145,41 @@ internal fun LibraryView(
Scaffold(
topBar = {
LibraryNavigationBar(
LibraryNavigationBar(currentDestination = currentTopLevelDestination,
savedItemViewModel = viewModel,
onSearchClicked = { navController.navigate(Routes.Search.route) },
onAddLinkClicked = { showAddLinkBottomSheet(viewModel) },
onSettingsIconClick = { navController.navigate(Routes.Settings.route) }
)
onAddLinkClicked = {
viewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
})
},
) { paddingValues ->
when (uiState) {
is LibraryUiState.Success -> {
LibraryViewContent(
viewModel,
itemsFilter = savedItemFilter,
activeLabels = activeLabels,
sortFilter = sortFilter,
updateSavedItemFilter = { viewModel.updateSavedItemFilter(it) },
updateSavedItemSortFilter = { viewModel.updateSavedItemSortFilter(it) },
setBottomSheetState = { viewModel.setBottomSheetState(it) },
updateAppliedLabels = { viewModel.updateAppliedLabels(it) },
isFollowingScreen = currentTopLevelDestination == TopLevelDestination.FOLLOWING,
{ viewModel.actionsMenuItemLiveData.postValue(null) },
savedItemViewModel = viewModel,
refresh = { viewModel.refresh() },
onUnarchive = { viewModel.unarchiveSavedItem(it) },
onArchive = { viewModel.archiveSavedItem(it) },
onDelete = { viewModel.deleteSavedItem(it) },
paddingValues = paddingValues,
uiState = uiState
)
items = (uiState as LibraryUiState.Success).items,
selectedItem = selectedItem,
onSavedItemAction = { id, action ->
viewModel.handleSavedItemAction(id, action)
},
{ viewModel.loadUsingSearchAPI() },
{ viewModel.initialLoad() })
}
is LibraryUiState.Loading -> {
Box(
modifier = Modifier
@ -156,9 +187,10 @@ internal fun LibraryView(
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(strokeCap = StrokeCap.Round)
CircularProgressIndicator(strokeCap = StrokeCap.Round)
}
}
else -> {
// TODO
}
@ -166,16 +198,18 @@ internal fun LibraryView(
}
}
fun showAddLinkBottomSheet(libraryViewModel: LibraryViewModel) {
libraryViewModel.bottomSheetState.value = LibraryBottomSheetState.ADD_LINK
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LabelBottomSheet(
libraryViewModel: LibraryViewModel,
deleteCurrentItem: () -> Unit,
labels: List<SavedItemLabel>,
currentSavedItemData: SavedItemWithLabelsAndHighlights?,
labelsViewModel: LabelsViewModel,
onDismiss: () -> Unit = {}
onDismiss: () -> Unit = {},
createNewSavedItemLabel: (String, String) -> Unit,
updateSavedItemLabels: (String, List<SavedItemLabel>) -> Unit,
activeLabels: List<SavedItemLabel>,
updateAppliedLabels: (List<SavedItemLabel>) -> Unit
) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
@ -185,50 +219,39 @@ fun LabelBottomSheet(
),
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(
listOf()
)
if (currentSavedItemData != null) {
LabelsSelectionSheetContent(
labels = labels,
LabelsSelectionSheetContent(labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = currentSavedItemData.labels,
onCancel = {
libraryViewModel.currentItem.value = null
deleteCurrentItem()
onDismiss()
},
isLibraryMode = false,
onSave = {
if (it != labels) {
libraryViewModel.updateSavedItemLabels(
savedItemID = currentSavedItemData.savedItem.savedItemId,
labels = it
)
updateSavedItemLabels(currentSavedItemData.savedItem.savedItemId, it)
}
libraryViewModel.currentItem.value = null
deleteCurrentItem()
onDismiss()
},
onCreateLabel = { newLabelName, labelHexValue ->
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
createNewSavedItemLabel(newLabelName, labelHexValue)
})
} else { // Is used in library mode
LabelsSelectionSheetContent(
labels = labels,
LabelsSelectionSheetContent(labels = labels,
labelsViewModel = labelsViewModel,
initialSelectedLabels = libraryViewModel.activeLabelsLiveData.value ?: listOf(),
initialSelectedLabels = activeLabels,
onCancel = { onDismiss() },
isLibraryMode = true,
onSave = {
libraryViewModel.updateAppliedLabels(it)
libraryViewModel.currentItem.value = null
updateAppliedLabels(it)
deleteCurrentItem()
onDismiss()
},
onCreateLabel = { newLabelName, labelHexValue ->
libraryViewModel.createNewSavedItemLabel(newLabelName, labelHexValue)
}
)
createNewSavedItemLabel(newLabelName, labelHexValue)
})
}
}
}
@ -236,8 +259,7 @@ fun LabelBottomSheet(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddLinkBottomSheet(
saveViewModel: SaveViewModel,
onDismiss: () -> Unit = {}
saveViewModel: SaveViewModel, onDismiss: () -> Unit = {}
) {
ModalBottomSheet(
onDismissRequest = { onDismiss() },
@ -247,17 +269,13 @@ fun AddLinkBottomSheet(
),
) {
AddLinkSheetContent(
viewModel = saveViewModel,
onCancel = {
saveViewModel.state.value = SaveState.DEFAULT
onDismiss()
},
onLinkAdded = {
saveViewModel.state.value = SaveState.DEFAULT
onDismiss()
}
)
AddLinkSheetContent(viewModel = saveViewModel, onCancel = {
saveViewModel.state.value = SaveState.DEFAULT
onDismiss()
}, onLinkAdded = {
saveViewModel.state.value = SaveState.DEFAULT
onDismiss()
})
}
}
@ -265,7 +283,9 @@ fun AddLinkBottomSheet(
@Composable
fun EditBottomSheet(
editInfoViewModel: EditInfoViewModel,
libraryViewModel: LibraryViewModel,
deleteCurrentItem: () -> Unit,
refresh: () -> Unit,
currentSavedItemUnderEdit: SavedItemWithLabelsAndHighlights?,
onDismiss: () -> Unit = {}
) {
ModalBottomSheet(
@ -275,23 +295,20 @@ fun EditBottomSheet(
skipPartiallyExpanded = true
),
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
EditInfoSheetContent(
savedItemId = currentSavedItemData?.savedItem?.savedItemId,
title = currentSavedItemData?.savedItem?.title,
author = currentSavedItemData?.savedItem?.author,
description = currentSavedItemData?.savedItem?.descriptionText,
EditInfoSheetContent(savedItemId = currentSavedItemUnderEdit?.savedItem?.savedItemId,
title = currentSavedItemUnderEdit?.savedItem?.title,
author = currentSavedItemUnderEdit?.savedItem?.author,
description = currentSavedItemUnderEdit?.savedItem?.descriptionText,
viewModel = editInfoViewModel,
onCancel = {
libraryViewModel.currentItem.value = null
deleteCurrentItem()
onDismiss()
},
onUpdated = {
libraryViewModel.currentItem.value = null
libraryViewModel.refresh()
deleteCurrentItem()
refresh()
onDismiss()
}
)
})
}
}
@ -299,9 +316,26 @@ fun EditBottomSheet(
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun LibraryViewContent(
libraryViewModel: LibraryViewModel,
itemsFilter: SavedItemFilter,
activeLabels: List<SavedItemLabel>,
sortFilter: SavedItemSortFilter,
updateSavedItemFilter:(SavedItemFilter) -> Unit,
updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit,
setBottomSheetState: (LibraryBottomSheetState) -> Unit,
updateAppliedLabels: (List<SavedItemLabel>) -> Unit,
isFollowingScreen: Boolean,
selectItem: () -> Unit,
savedItemViewModel: SavedItemViewModel,
refresh: () -> Unit,
onUnarchive: (String) -> Unit,
onArchive: (String) -> Unit,
onDelete: (String) -> Unit,
paddingValues: PaddingValues,
uiState: LibraryUiState
items: List<SavedItemWithLabelsAndHighlights>,
selectedItem: SavedItemWithLabelsAndHighlights?,
onSavedItemAction: (String, SavedItemAction) -> Unit,
loadUsingSearchAPI: () -> Unit,
initialLoad: () -> Unit
) {
val context = LocalContext.current
val listState = rememberLazyListState()
@ -311,13 +345,11 @@ fun LibraryViewContent(
LaunchedEffect(true) {
// fetch something
delay(1500)
libraryViewModel.refresh()
refresh()
pullToRefreshState.endRefresh()
}
}
val selectedItem: SavedItemWithLabelsAndHighlights? by libraryViewModel.actionsMenuItemLiveData.observeAsState()
Box(
modifier = Modifier
.padding(top = paddingValues.calculateTopPadding())
@ -325,55 +357,63 @@ fun LibraryViewContent(
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
Column {
LibraryFilterBar()
LibraryFilterBar(
isFollowingScreen,
itemsFilter,
sortFilter,
activeLabels,
setBottomSheetState,
updateSavedItemFilter,
updateSavedItemSortFilter,
updateAppliedLabels
)
HorizontalDivider()
LazyColumn(
state = listState,
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
items(
items = (uiState as LibraryUiState.Success).items,
key = { item -> item.savedItem.savedItemId }
) { cardDataWithLabels ->
items(items = items,
key = { item -> item.savedItem.savedItemId }) { cardDataWithLabels ->
val swipeThreshold = 0.45f
val currentThresholdFraction = remember { mutableStateOf(0f) }
val currentItem by rememberUpdatedState(cardDataWithLabels.savedItem)
val swipeState = rememberDismissState(
confirmStateChange = {
when(it) {
DismissValue.Default -> {
val swipeState = rememberDismissState(confirmStateChange = {
when (it) {
DismissValue.Default -> {
return@rememberDismissState false
}
DismissValue.DismissedToEnd -> {
if (currentThresholdFraction.value < swipeThreshold) {
return@rememberDismissState false
}
DismissValue.DismissedToEnd -> {
if (currentThresholdFraction.value < swipeThreshold) {
return@rememberDismissState false
}
}
DismissValue.DismissedToStart -> {
if (currentThresholdFraction.value < swipeThreshold) {
return@rememberDismissState false
}
}
}
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
if (currentItem.isArchived) {
libraryViewModel.unarchiveSavedItem(currentItem.savedItemId)
} else {
libraryViewModel.archiveSavedItem(currentItem.savedItemId)
DismissValue.DismissedToStart -> {
if (currentThresholdFraction.value < swipeThreshold) {
return@rememberDismissState false
}
} else if (it == DismissValue.DismissedToStart) { // Deleting.
libraryViewModel.deleteSavedItem(currentItem.savedItemId)
}
true
}
)
if (it == DismissValue.DismissedToEnd) { // Archiving/UnArchiving.
if (currentItem.isArchived) {
onUnarchive(currentItem.savedItemId)
} else {
onArchive(currentItem.savedItemId)
}
} else if (it == DismissValue.DismissedToStart) { // Deleting.
onDelete(currentItem.savedItemId)
}
true
})
SwipeToDismiss(
state = swipeState,
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
directions = setOf(
DismissDirection.StartToEnd, DismissDirection.EndToStart
),
dismissThresholds = { FractionalThreshold(swipeThreshold) },
background = {
val direction = swipeState.dismissDirection ?: return@SwipeToDismiss
@ -401,8 +441,7 @@ fun LibraryViewContent(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = alignment
.padding(horizontal = 20.dp), contentAlignment = alignment
) {
currentThresholdFraction.value = swipeState.progress.fraction
Icon(
@ -420,12 +459,11 @@ fun LibraryViewContent(
labels = cardDataWithLabels.labels,
highlights = cardDataWithLabels.highlights
)
SavedItemCard(
selected = selected,
savedItemViewModel = libraryViewModel,
SavedItemCard(selected = selected,
savedItemViewModel = savedItemViewModel,
savedItem = savedItem,
onClickHandler = {
libraryViewModel.actionsMenuItemLiveData.postValue(null)
selectItem()
val activityClass =
if (currentItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java
val intent = Intent(context, activityClass)
@ -433,12 +471,10 @@ fun LibraryViewContent(
context.startActivity(intent)
},
actionHandler = {
libraryViewModel.handleSavedItemAction(
currentItem.savedItemId,
it
onSavedItemAction(
currentItem.savedItemId, it
)
}
)
})
},
)
when {
@ -450,12 +486,12 @@ fun LibraryViewContent(
}
InfiniteListHandler(listState = listState) {
if ((uiState as LibraryUiState.Success).items.isEmpty()) {
if (items.isEmpty()) {
Log.d("sync", "loading with load func")
libraryViewModel.initialLoad()
initialLoad()
} else {
Log.d("sync", "loading with search api")
libraryViewModel.loadUsingSearchAPI()
loadUsingSearchAPI()
}
}
@ -463,8 +499,6 @@ fun LibraryViewContent(
modifier = Modifier.align(Alignment.TopCenter),
state = pullToRefreshState,
)
// LabelsSelectionSheet(viewModel = libraryViewModel)
}
}
@ -482,9 +516,7 @@ private fun Reset(state: DismissState) {
@Composable
fun InfiniteListHandler(
listState: LazyListState,
buffer: Int = 2,
onLoadMore: () -> Unit
listState: LazyListState, buffer: Int = 2, onLoadMore: () -> Unit
) {
val loadMore = remember {
derivedStateOf {
@ -497,9 +529,7 @@ fun InfiniteListHandler(
}
LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
snapshotFlow { loadMore.value }.distinctUntilChanged().collect {
onLoadMore()
}
}

View file

@ -1,5 +1,6 @@
package app.omnivore.omnivore.feature.library
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@ -7,30 +8,17 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.archiveSavedItem
import app.omnivore.omnivore.core.data.deleteSavedItem
import app.omnivore.omnivore.core.data.fetchSavedItemContent
import app.omnivore.omnivore.core.data.isSavedItemContentStoredInDB
import app.omnivore.omnivore.core.data.librarySearch
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.data.sync
import app.omnivore.omnivore.core.data.syncLabels
import app.omnivore.omnivore.core.data.syncOfflineItemsWithServerIfNeeded
import app.omnivore.omnivore.core.data.unarchiveSavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.feature.setSavedItemLabels
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.api.Optional
import app.omnivore.omnivore.core.datastore.followingTabActive
import app.omnivore.omnivore.core.datastore.lastUsedSavedItemFilter
import app.omnivore.omnivore.core.datastore.lastUsedSavedItemSortFilter
import app.omnivore.omnivore.core.datastore.libraryLastSyncTimestamp
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
@ -50,11 +38,9 @@ import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider,
private val datastoreRepository: DatastoreRepository,
private val libraryRepository: LibraryRepository,
@ApplicationContext private val applicationContext: Context
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
@ -63,8 +49,11 @@ class LibraryViewModel @Inject constructor(
var snackbarMessage by mutableStateOf<String?>(null)
private set
private val folders = MutableStateFlow(listOf<String>())
private val _libraryQuery = MutableStateFlow(
LibraryQuery(
folders = folders.value,
allowedArchiveStates = listOf(0),
sortKey = "newest",
requiredLabels = listOf(),
@ -73,50 +62,69 @@ class LibraryViewModel @Inject constructor(
)
)
private val followingTabActiveState: StateFlow<Boolean> = datastoreRepository.getBoolean(followingTabActive).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false
)
private fun updateLibraryQuery() {
_libraryQuery.value = _libraryQuery.value.copy(folders = folders.value)
}
init {
viewModelScope.launch {
followingTabActiveState.collect { tabActive ->
if (tabActive) {
folders.value = listOf("inbox")
} else {
folders.value = listOf("inbox","following")
}
updateLibraryQuery()
}
}
}
val uiState: StateFlow<LibraryUiState> = _libraryQuery.flatMapLatest { query ->
libraryRepository.getSavedItems(query)
}
.map(LibraryUiState::Success)
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = LibraryUiState.Loading
)
}.map(LibraryUiState::Success).stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = LibraryUiState.Loading
)
val appliedFilterState = MutableStateFlow(SavedItemFilter.INBOX)
val appliedSortFilterLiveData = MutableStateFlow(SavedItemSortFilter.NEWEST)
val bottomSheetState = MutableStateFlow(LibraryBottomSheetState.HIDDEN)
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
val bottomSheetState = MutableLiveData(LibraryBottomSheetState.HIDDEN)
val currentItem = mutableStateOf<String?>(null)
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
val labelsState = libraryRepository.getSavedItemsLabels().stateIn(
scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf()
)
val activeLabels = MutableStateFlow<List<SavedItemLabel>>(listOf())
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
var isRefreshing by mutableStateOf(false)
private var hasLoadedInitialFilters = false
private fun loadInitialFilterValues() {
if (hasLoadedInitialFilters) {
return
}
hasLoadedInitialFilters = false
viewModelScope.launch {
withContext(Dispatchers.IO) {
dataService.syncLabels()
}
}
syncLabels()
viewModelScope.launch {
handleFilterChanges()
for (slug in contentRequestChannel) {
CoroutineScope(Dispatchers.IO).launch {
dataService.fetchSavedItemContent(slug)
}
libraryRepository.fetchSavedItemContent(slug)
}
}
updateSavedItemFilter(appliedFilterState.value)
}
private fun syncLabels() {
viewModelScope.launch {
val labels = libraryRepository.getLabels()
libraryRepository.insertAllLabels(labels)
}
}
fun clearSnackbarMessage() {
@ -125,12 +133,15 @@ class LibraryViewModel @Inject constructor(
fun refresh() {
librarySearchCursor = null
isRefreshing = true
load()
}
fun setBottomSheetState(state: LibraryBottomSheetState) {
bottomSheetState.value = state
}
private fun getLastSyncTime(): Instant? = runBlocking {
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
datastoreRepository.getString(libraryLastSyncTimestamp)?.let {
try {
return@let Instant.parse(it)
} catch (e: Exception) {
@ -141,13 +152,8 @@ class LibraryViewModel @Inject constructor(
fun initialLoad() {
if (getLastSyncTime() == null) {
hasLoadedInitialFilters = false
librarySearchCursor = null
}
if (hasLoadedInitialFilters) {
return
}
load()
}
@ -162,24 +168,19 @@ class LibraryViewModel @Inject constructor(
fun loadUsingSearchAPI() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = dataService.librarySearch(
cursor = librarySearchCursor, query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
val result = libraryRepository.librarySearch(
cursor = librarySearchCursor,
query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
result.savedItems.map {
val isSavedInDB = libraryRepository.isSavedItemContentStoredInDB(it.savedItem.slug)
result.savedItems.map {
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug)
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
}
}
@ -187,15 +188,15 @@ class LibraryViewModel @Inject constructor(
fun updateSavedItemFilter(filter: SavedItemFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemFilter, filter.rawValue)
appliedFilterLiveData.value = filter
datastoreRepository.putString(lastUsedSavedItemFilter, filter.rawValue)
appliedFilterState.value = filter
handleFilterChanges()
}
}
fun updateSavedItemSortFilter(filter: SavedItemSortFilter) {
viewModelScope.launch {
datastoreRepo.putString(DatastoreKeys.lastUsedSavedItemSortFilter, filter.rawValue)
datastoreRepository.putString(lastUsedSavedItemSortFilter, filter.rawValue)
appliedSortFilterLiveData.value = filter
handleFilterChanges()
}
@ -203,7 +204,7 @@ class LibraryViewModel @Inject constructor(
fun updateAppliedLabels(labels: List<SavedItemLabel>) {
viewModelScope.launch {
activeLabelsLiveData.value = labels
activeLabels.value = labels
handleFilterChanges()
}
}
@ -211,50 +212,47 @@ class LibraryViewModel @Inject constructor(
private fun handleFilterChanges() {
librarySearchCursor = null
if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) {
val sortKey = when (appliedSortFilterLiveData.value) {
SavedItemSortFilter.NEWEST -> "newest"
SavedItemSortFilter.OLDEST -> "oldest"
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
else -> "newest"
}
val allowedArchiveStates = when (appliedFilterLiveData.value) {
SavedItemFilter.ALL -> listOf(0, 1)
SavedItemFilter.ARCHIVED -> listOf(1)
else -> listOf(0)
}
val allowedContentReaders = when (appliedFilterLiveData.value) {
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
else -> listOf("WEB", "PDF", "EPUB")
}
var requiredLabels = when (appliedFilterLiveData.value) {
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
SavedItemFilter.FEEDS -> listOf("RSS")
else -> (activeLabelsLiveData.value ?: listOf()).map { it.name }
}
activeLabelsLiveData.value?.let { it ->
requiredLabels = requiredLabels + it.map { it.name }
}
val excludeLabels = when (appliedFilterLiveData.value) {
SavedItemFilter.READ_LATER -> listOf("Newsletter", "RSS")
else -> listOf()
}
_libraryQuery.value = LibraryQuery(
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
requiredLabels = requiredLabels,
excludedLabels = excludeLabels,
allowedContentReaders = allowedContentReaders
)
val sortKey = when (appliedSortFilterLiveData.value) {
SavedItemSortFilter.NEWEST -> "newest"
SavedItemSortFilter.OLDEST -> "oldest"
SavedItemSortFilter.RECENTLY_READ -> "recentlyRead"
SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished"
}
val allowedArchiveStates = when (appliedFilterState.value) {
SavedItemFilter.ALL -> listOf(0, 1)
SavedItemFilter.ARCHIVED -> listOf(1)
else -> listOf(0)
}
val allowedContentReaders = when (appliedFilterState.value) {
SavedItemFilter.FILES -> listOf("PDF", "EPUB")
else -> listOf("WEB", "PDF", "EPUB")
}
var requiredLabels = when (appliedFilterState.value) {
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
SavedItemFilter.FEEDS -> listOf("RSS")
else -> activeLabels.value.map { it.name }
}
activeLabels.value.let { it ->
requiredLabels = requiredLabels + it.map { it.name }
}
val excludeLabels = when (appliedFilterState.value) {
SavedItemFilter.NON_FEED -> listOf("Newsletter", "RSS")
else -> listOf()
}
_libraryQuery.value = LibraryQuery(
folders = folders.value,
allowedArchiveStates = allowedArchiveStates,
sortKey = sortKey,
requiredLabels = requiredLabels,
excludedLabels = excludeLabels,
allowedContentReaders = allowedContentReaders
)
}
private suspend fun syncItems() {
@ -268,9 +266,6 @@ class LibraryViewModel @Inject constructor(
count = 0,
startTime = syncStart.toString()
)
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
}
}
@ -281,8 +276,8 @@ class LibraryViewModel @Inject constructor(
startTime: String,
isInitialBatch: Boolean = true
) {
dataService.syncOfflineItemsWithServerIfNeeded()
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
libraryRepository.syncOfflineItemsWithServerIfNeeded()
val result = libraryRepository.sync(since = since, cursor = cursor, limit = 20)
// Fetch content for the initial batch only
if (isInitialBatch) {
@ -303,43 +298,43 @@ class LibraryViewModel @Inject constructor(
isInitialBatch = false
)
} else {
datastoreRepo.putString(DatastoreKeys.libraryLastSyncTimestamp, startTime)
datastoreRepository.putString(libraryLastSyncTimestamp, startTime)
}
}
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
override fun handleSavedItemAction(itemId: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
deleteSavedItem(itemID)
deleteSavedItem(itemId)
}
SavedItemAction.Archive -> {
archiveSavedItem(itemID)
archiveSavedItem(itemId)
}
SavedItemAction.Unarchive -> {
unarchiveSavedItem(itemID)
unarchiveSavedItem(itemId)
}
SavedItemAction.EditLabels -> {
currentItem.value = itemID
currentItem.value = itemId
bottomSheetState.value = LibraryBottomSheetState.LABEL
}
SavedItemAction.EditInfo -> {
currentItem.value = itemID
currentItem.value = itemId
bottomSheetState.value = LibraryBottomSheetState.EDIT
}
SavedItemAction.MarkRead -> {
viewModelScope.launch {
libraryRepository.updateReadingProgress(itemID, 100.0, 0)
libraryRepository.updateReadingProgress(itemId, 100.0, 0)
}
}
SavedItemAction.MarkUnread -> {
viewModelScope.launch {
libraryRepository.updateReadingProgress(itemID, 0.0, 0)
libraryRepository.updateReadingProgress(itemId, 0.0, 0)
}
}
}
@ -348,66 +343,39 @@ class LibraryViewModel @Inject constructor(
fun deleteSavedItem(itemID: String) {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
libraryRepository.deleteSavedItem(itemID)
}
}
fun archiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
libraryRepository.archiveSavedItem(itemID)
}
}
fun unarchiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
libraryRepository.unarchiveSavedItem(itemID)
}
}
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
fun updateSavedItemLabels(savedItemId: String, labels: List<SavedItemLabel>) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = setSavedItemLabels(
networker = networker,
dataService = dataService,
savedItemID = savedItemID,
labels = labels
)
snackbarMessage = if (result) {
resourceProvider.getString(R.string.library_view_model_snackbar_success)
} else {
resourceProvider.getString(R.string.library_view_model_snackbar_error)
}
CoroutineScope(Dispatchers.Main).launch {
handleFilterChanges()
}
val result = libraryRepository.setSavedItemLabels(
itemId = savedItemId, labels = labels
)
snackbarMessage = if (result) {
applicationContext.getString(R.string.library_view_model_snackbar_success)
} else {
applicationContext.getString(R.string.library_view_model_snackbar_error)
}
handleFilterChanges()
}
}
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val newLabel = networker.createNewLabel(
CreateLabelInput(
color = Optional.presentIfNotNull(hexColorValue), name = labelName
)
)
newLabel?.let {
val savedItemLabel = SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = it.createdAt as String?,
labelDescription = it.description
)
dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel))
}
}
libraryRepository.createNewSavedItemLabel(labelName, hexColorValue)
}
}
@ -415,15 +383,14 @@ class LibraryViewModel @Inject constructor(
currentItem.value?.let { itemID ->
return (uiState.value as LibraryUiState.Success).items.first { it.savedItem.savedItemId == itemID }
}
return null
}
private fun searchQueryString(): String {
var query =
"${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}"
"${appliedFilterState.value.queryString} ${appliedSortFilterLiveData.value.queryString}"
activeLabelsLiveData.value?.let {
activeLabels.value.let {
if (it.isNotEmpty()) {
query += " label:"
query += it.joinToString { label -> label.name }

View file

@ -7,38 +7,44 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight
enum class SavedItemFilter(val displayText: String, val rawValue: String, val queryString: String) {
INBOX("Inbox", rawValue = "inbox", "in:inbox"), READ_LATER(
"Non-Feed Items", "nonFeed", "no:subscription"
),
FEEDS("Feeds", "feeds", "in:inbox label:RSS"), NEWSLETTERS(
"Newsletters", "newsletters", "in:inbox label:Newsletter"
),
// RECOMMENDED("Recommended", "recommended", "recommendedBy:*"),
ALL("All", "all", "in:all"), ARCHIVED("Archived", "archived", "in:archive"),
// HAS_HIGHLIGHTS("Highlighted", "hasHighlights", "has:highlights"),
FOLLOWING("Following", "following", "in:following use:folders"),
INBOX("Inbox", rawValue = "inbox", "in:inbox use:folders"),
NON_FEED("Non-Feed Items", "nonFeed", "no:subscription"),
FEEDS("Feeds", "feeds", "in:inbox label:RSS"),
NEWSLETTERS("Newsletters", "newsletters", "in:inbox label:Newsletter"),
ALL("All", "all", "in:all"),
ARCHIVED("Archived", "archived", "in:archive"),
FILES("Files", "files", "type:file"),
}
@Composable
fun SavedItemFilterContextMenu(
isExpanded: Boolean, onDismiss: () -> Unit, actionHandler: (SavedItemFilter) -> Unit
isFollowingScreen: Boolean,
isExpanded: Boolean, onDismiss: () -> Unit,
actionHandler: (SavedItemFilter) -> Unit
) {
val filters = if (isFollowingScreen) {
listOf(
SavedItemFilter.FOLLOWING,
SavedItemFilter.FEEDS,
SavedItemFilter.NEWSLETTERS
)
} else {
listOf(
SavedItemFilter.INBOX,
SavedItemFilter.NON_FEED,
SavedItemFilter.ALL,
SavedItemFilter.ARCHIVED,
SavedItemFilter.FILES
)
}
DropdownMenu(
expanded = isExpanded, onDismissRequest = onDismiss
) {
// Displaying only a subset of filters until we figure out the Room DB queries (and labels)
// SavedItemFilter.values().forEach {
listOf(
SavedItemFilter.INBOX,
SavedItemFilter.READ_LATER,
SavedItemFilter.NEWSLETTERS,
SavedItemFilter.FEEDS,
SavedItemFilter.ALL,
SavedItemFilter.ARCHIVED,
SavedItemFilter.FILES
).forEach {
filters.forEach {
DropdownMenuItem(text = { Text(text = it.displayText, fontWeight = FontWeight.Normal) },
onClick = {
actionHandler(it)

View file

@ -8,5 +8,5 @@ interface SavedItemViewModel {
val actionsMenuItemLiveData: MutableLiveData<SavedItemWithLabelsAndHighlights?>
get() = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
fun handleSavedItemAction(itemID: String, action: SavedItemAction)
fun handleSavedItemAction(itemId: String, action: SavedItemAction)
}

View file

@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
@ -46,8 +47,8 @@ import app.omnivore.omnivore.feature.savedItemViews.TypeaheadSearchCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchView(
viewModel: SearchViewModel,
navController: NavHostController
navController: NavHostController,
viewModel: SearchViewModel = hiltViewModel()
) {
val isRefreshing: Boolean by viewModel.isRefreshing.observeAsState(false)
val typeaheadMode: Boolean by viewModel.typeaheadMode.observeAsState(true)

View file

@ -153,23 +153,23 @@ class SearchViewModel @Inject constructor(
isRefreshing.postValue(false)
}
override fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
override fun handleSavedItemAction(itemId: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
dataService.deleteSavedItem(itemId)
}
}
SavedItemAction.Archive -> {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
dataService.archiveSavedItem(itemId)
}
}
SavedItemAction.Unarchive -> {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
dataService.unarchiveSavedItem(itemId)
}
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature.settings
package app.omnivore.omnivore.feature.profile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults

View file

@ -0,0 +1,103 @@
package app.omnivore.omnivore.feature.profile
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.navigation.Routes
internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/releases"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SettingsScreen(
navController: NavHostController,
loginViewModel: LoginViewModel = hiltViewModel()
) {
Scaffold(topBar = {
TopAppBar(
title = { Text(stringResource(R.string.profile_view_title)) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
)
}) { paddingValues ->
SettingsViewContent(
loginViewModel = loginViewModel,
navController = navController,
paddingValues = paddingValues
)
}
}
@Composable
fun SettingsViewContent(
loginViewModel: LoginViewModel,
navController: NavHostController,
paddingValues: PaddingValues
) {
val showLogoutDialog = remember { mutableStateOf(false) }
val state = rememberLazyListState()
LazyColumn(
state = state,
contentPadding = paddingValues,
) {
item {
TextPreferenceWidget(
title = stringResource(R.string.profile_filters),
onPreferenceClick = { navController.navigate(Routes.Filters.route) },
)
}
item { HorizontalDivider() }
item {
TextPreferenceWidget(
title = stringResource(R.string.profile_manage_account),
onPreferenceClick = { navController.navigate(Routes.Account.route) },
)
}
item {
TextPreferenceWidget(
title = stringResource(R.string.about_logout),
onPreferenceClick = { showLogoutDialog.value = true },
)
}
item {
TextPreferenceWidget(
title = stringResource(R.string.about_view_title),
onPreferenceClick = { navController.navigate(Routes.About.route) },
)
}
}
if (showLogoutDialog.value) {
LogoutDialog { performLogout ->
if (performLogout) {
loginViewModel.logout()
}
showLogoutDialog.value = false
}
}
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature.settings
package app.omnivore.omnivore.feature.profile
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -7,9 +7,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.libraryLastSyncTimestamp
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.viewer
import app.omnivore.omnivore.utils.DatastoreKeys
import dagger.hilt.android.lifecycle.HiltViewModel
import io.intercom.android.sdk.Intercom
import io.intercom.android.sdk.IntercomSpace
@ -18,7 +18,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
class ProfileViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository
@ -33,7 +33,7 @@ class SettingsViewModel @Inject constructor(
isResettingData = true
viewModelScope.launch {
datastoreRepo.clearValue(DatastoreKeys.libraryLastSyncTimestamp)
datastoreRepo.clearValue(libraryLastSyncTimestamp)
dataService.clearDatabase()
delay(1000)
isResettingData = false

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature.settings.about
package app.omnivore.omnivore.feature.profile.about
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@ -27,17 +27,17 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import app.omnivore.omnivore.BuildConfig
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget
import app.omnivore.omnivore.core.ui.LinkIcon
import app.omnivore.omnivore.feature.settings.RELEASE_URL
import app.omnivore.omnivore.feature.settings.SettingRow
import app.omnivore.omnivore.feature.settings.SettingsViewModel
import app.omnivore.omnivore.feature.profile.ProfileViewModel
import app.omnivore.omnivore.feature.profile.RELEASE_URL
import app.omnivore.omnivore.navigation.Routes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AboutScreen(
navController: NavHostController,
settingsViewModel: SettingsViewModel = hiltViewModel()
settingsViewModel: ProfileViewModel = hiltViewModel()
) {
val uriHandler = LocalUriHandler.current
@ -68,41 +68,45 @@ internal fun AboutScreen(
}
item {
SettingRow(
TextPreferenceWidget(
title = stringResource(R.string.about_view_row_whats_new),
onClick = { uriHandler.openUri(RELEASE_URL) },
onPreferenceClick = { uriHandler.openUri(RELEASE_URL) },
)
}
item {
SettingRow(title = stringResource(R.string.settings_view_setting_row_documentation)) {
navController.navigate(Routes.Documentation.route)
}
TextPreferenceWidget(
title = stringResource(R.string.about_documentation),
onPreferenceClick = { navController.navigate(Routes.Documentation.route) },
)
}
item {
SettingRow(title = stringResource(R.string.settings_view_setting_row_feedback)) {
settingsViewModel.presentIntercom()
}
TextPreferenceWidget(
title = stringResource(R.string.about_feedback),
onPreferenceClick = { settingsViewModel.presentIntercom() },
)
}
item {
SettingRow(title = stringResource(R.string.settings_view_setting_row_privacy_policy)) {
navController.navigate(Routes.PrivacyPolicy.route)
}
TextPreferenceWidget(
title = stringResource(R.string.about_privacy_policy),
onPreferenceClick = { navController.navigate(Routes.PrivacyPolicy.route) },
)
}
item {
SettingRow(title = stringResource(R.string.settings_view_setting_row_terms_and_conditions)) {
navController.navigate(Routes.TermsAndConditions.route)
}
TextPreferenceWidget(
title = stringResource(R.string.about_terms_and_conditions),
onPreferenceClick = { navController.navigate(Routes.TermsAndConditions.route) },
)
}
item {
SettingRow(
TextPreferenceWidget(
title = stringResource(R.string.about_view_row_version),
subtitle = getVersionName(),
onClick = { },
onPreferenceClick = { },
)
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature.settings.about
package app.omnivore.omnivore.feature.profile.about
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature.settings.account
package app.omnivore.omnivore.feature.profile.account
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
@ -20,15 +20,15 @@ import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.settings.SettingRow
import app.omnivore.omnivore.feature.settings.SettingsViewModel
import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget
import app.omnivore.omnivore.feature.profile.ProfileViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AccountScreen(
navController: NavHostController,
snackbarHostState: SnackbarHostState,
settingsViewModel: SettingsViewModel = hiltViewModel()
settingsViewModel: ProfileViewModel = hiltViewModel()
) {
LaunchedEffect(settingsViewModel.isResettingData) {
if (settingsViewModel.isResettingData) {
@ -72,11 +72,9 @@ internal fun AccountScreen(
contentPadding = contentPadding,
) {
item {
SettingRow(
TextPreferenceWidget(
title = stringResource(R.string.manage_account_action_reset_data_cache),
onClick = {
settingsViewModel.resetDataCache()
},
onPreferenceClick = { settingsViewModel.resetDataCache() },
)
}
}

View file

@ -0,0 +1,62 @@
package app.omnivore.omnivore.feature.profile.filters
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.component.SwitchPreferenceWidget
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun FiltersScreen(
navController: NavHostController,
filtersViewModel: FiltersViewModel = hiltViewModel()
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.profile_filters)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
)
},
) { contentPadding ->
LazyColumn(
contentPadding = contentPadding,
) {
item {
val followingTabActive by filtersViewModel.followingTabActiveState.collectAsStateWithLifecycle()
SwitchPreferenceWidget(
title = stringResource(R.string.hide_following_tab),
checked = !followingTabActive,
onCheckedChanged = { filtersViewModel.setFollowingTabActiveState(!it) },
)
}
}
}
}

View file

@ -0,0 +1,30 @@
package app.omnivore.omnivore.feature.profile.filters
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.followingTabActive
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class FiltersViewModel @Inject constructor(
private val datastoreRepository: DatastoreRepository
) : ViewModel() {
val followingTabActiveState: StateFlow<Boolean> = datastoreRepository.getBoolean(followingTabActive).stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(),
initialValue = false
)
fun setFollowingTabActiveState(value: Boolean) {
viewModelScope.launch {
datastoreRepository.putBoolean(followingTabActive, value)
}
}
}

View file

@ -15,55 +15,53 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_EXPANDE
import com.google.android.material.bottomsheet.BottomSheetDialog
class AnnotationEditFragment : DialogFragment() {
private var onSave: (String) -> Unit = {}
private var onCancel: () -> Unit = {}
private var initialAnnotation: String = ""
private var onSave: (String) -> Unit = {}
private var onCancel: () -> Unit = {}
private var initialAnnotation: String = ""
fun configure(
initialAnnotation: String,
onSave: (String) -> Unit,
onCancel: () -> Unit,
) {
this.initialAnnotation = initialAnnotation
this.onSave = onSave
this.onCancel = onCancel
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
(dialog as? BottomSheetDialog)?.let {
it.behavior.skipCollapsed = true
it.behavior.state = STATE_EXPANDED
}
dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
OmnivoreTheme {
EditNoteModal(initialValue = initialAnnotation, onDismiss = { save, text ->
if (save) {
onSave(text ?: "")
} else {
onCancel()
}
dismissNow()
})
}
}
fun configure(
initialAnnotation: String,
onSave: (String) -> Unit,
onCancel: () -> Unit,
) {
this.initialAnnotation = initialAnnotation
this.onSave = onSave
this.onCancel = onCancel
}
}
override fun onDismiss(dialog: DialogInterface) {
onCancel()
super.onDismiss(dialog)
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
(dialog as? BottomSheetDialog)?.let {
it.behavior.skipCollapsed = true
it.behavior.state = STATE_EXPANDED
}
dialog?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
OmnivoreTheme {
EditNoteModal(initialValue = initialAnnotation, onDismiss = { save, text ->
if (save) {
onSave(text ?: "")
} else {
onCancel()
}
dismissNow()
})
}
}
}
}
override fun onDismiss(dialog: DialogInterface) {
onCancel()
super.onDismiss(dialog)
}
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature
package app.omnivore.omnivore.feature.reader
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput

View file

@ -28,15 +28,20 @@ import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.preferredTheme
import app.omnivore.omnivore.core.datastore.preferredWebFontFamily
import app.omnivore.omnivore.core.datastore.preferredWebFontSize
import app.omnivore.omnivore.core.datastore.preferredWebLineHeight
import app.omnivore.omnivore.core.datastore.preferredWebMaxWidthPercentage
import app.omnivore.omnivore.core.datastore.prefersJustifyText
import app.omnivore.omnivore.core.datastore.prefersWebHighContrastText
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.core.network.saveUrl
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.feature.components.HighlightColor
import app.omnivore.omnivore.feature.library.SavedItemAction
import app.omnivore.omnivore.feature.setSavedItemLabels
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
@ -457,20 +462,20 @@ class WebReaderViewModel @Inject constructor(
.asLiveData()
fun storedWebPreferences(isDarkMode: Boolean): WebPreferences = runBlocking {
val storedFontSize = datastoreRepo.getInt(DatastoreKeys.preferredWebFontSize)
val storedLineHeight = datastoreRepo.getInt(DatastoreKeys.preferredWebLineHeight)
val storedMaxWidth = datastoreRepo.getInt(DatastoreKeys.preferredWebMaxWidthPercentage)
val storedFontSize = datastoreRepo.getInt(preferredWebFontSize)
val storedLineHeight = datastoreRepo.getInt(preferredWebLineHeight)
val storedMaxWidth = datastoreRepo.getInt(preferredWebMaxWidthPercentage)
val storedFontFamily =
datastoreRepo.getString(DatastoreKeys.preferredWebFontFamily) ?: WebFont.SYSTEM.rawValue
datastoreRepo.getString(preferredWebFontFamily) ?: WebFont.SYSTEM.rawValue
val storedThemePreference =
datastoreRepo.getString(DatastoreKeys.preferredTheme) ?: "System"
datastoreRepo.getString(preferredTheme) ?: "System"
val storedWebFont =
WebFont.values().firstOrNull { it.rawValue == storedFontFamily } ?: WebFont.values()
WebFont.entries.firstOrNull { it.rawValue == storedFontFamily } ?: WebFont.entries
.first()
val prefersHighContrastFont =
datastoreRepo.getString(DatastoreKeys.prefersWebHighContrastText) == "true"
datastoreRepo.getString(prefersWebHighContrastText) == "true"
val prefersJustifyText = datastoreRepo.getString(DatastoreKeys.prefersJustifyText) == "true"
val shouldUseVolumeRockerForScroll = datastoreRepo.getString(DatastoreKeys.volumeForScroll) != "false"
@ -499,7 +504,7 @@ class WebReaderViewModel @Inject constructor(
Log.d("theme", "Setting theme key: $newThemeKey")
runBlocking {
datastoreRepo.putString(DatastoreKeys.preferredTheme, newThemeKey)
datastoreRepo.putString(preferredTheme, newThemeKey)
}
val script =
@ -509,7 +514,7 @@ class WebReaderViewModel @Inject constructor(
fun setFontSize(newFontSize: Int) {
runBlocking {
datastoreRepo.putInt(DatastoreKeys.preferredWebFontSize, newFontSize)
datastoreRepo.putInt(preferredWebFontSize, newFontSize)
}
val script =
"var event = new Event('updateFontSize');event.fontSize = '$newFontSize';document.dispatchEvent(event);"
@ -519,7 +524,7 @@ class WebReaderViewModel @Inject constructor(
fun setMaxWidthPercentage(newMaxWidthPercentageValue: Int) {
runBlocking {
datastoreRepo.putInt(
DatastoreKeys.preferredWebMaxWidthPercentage,
preferredWebMaxWidthPercentage,
newMaxWidthPercentageValue
)
}
@ -530,7 +535,7 @@ class WebReaderViewModel @Inject constructor(
fun setLineHeight(newLineHeight: Int) {
runBlocking {
datastoreRepo.putInt(DatastoreKeys.preferredWebLineHeight, newLineHeight)
datastoreRepo.putInt(preferredWebLineHeight, newLineHeight)
}
val script =
"var event = new Event('updateLineHeight');event.lineHeight = '$newLineHeight';document.dispatchEvent(event);"
@ -540,7 +545,7 @@ class WebReaderViewModel @Inject constructor(
fun updateHighContrastTextPreference(prefersHighContrastText: Boolean) {
runBlocking {
datastoreRepo.putString(
DatastoreKeys.prefersWebHighContrastText,
prefersWebHighContrastText,
prefersHighContrastText.toString()
)
}
@ -552,7 +557,7 @@ class WebReaderViewModel @Inject constructor(
fun updateJustifyText(justifyText: Boolean) {
runBlocking {
datastoreRepo.putString(DatastoreKeys.prefersJustifyText, justifyText.toString())
datastoreRepo.putString(prefersJustifyText, justifyText.toString())
}
val script =
"var event = new Event('updateJustifyText');event.justifyText = $justifyText;document.dispatchEvent(event);"
@ -568,7 +573,7 @@ class WebReaderViewModel @Inject constructor(
fun applyWebFont(font: WebFont) {
runBlocking {
datastoreRepo.putString(DatastoreKeys.preferredWebFontFamily, font.rawValue)
datastoreRepo.putString(preferredWebFontFamily, font.rawValue)
}
val script =

View file

@ -1,5 +1,7 @@
package app.omnivore.omnivore.feature.root
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
@ -10,6 +12,10 @@ import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -20,37 +26,59 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn
import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.feature.auth.WelcomeScreen
import app.omnivore.omnivore.feature.components.LabelsViewModel
import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel
import app.omnivore.omnivore.feature.following.FollowingScreen
import app.omnivore.omnivore.feature.library.LibraryView
import app.omnivore.omnivore.feature.library.SearchView
import app.omnivore.omnivore.feature.library.SearchViewModel
import app.omnivore.omnivore.feature.save.SaveViewModel
import app.omnivore.omnivore.feature.settings.SettingsScreen
import app.omnivore.omnivore.feature.settings.about.AboutScreen
import app.omnivore.omnivore.feature.settings.account.AccountScreen
import app.omnivore.omnivore.feature.profile.SettingsScreen
import app.omnivore.omnivore.feature.profile.about.AboutScreen
import app.omnivore.omnivore.feature.profile.account.AccountScreen
import app.omnivore.omnivore.feature.profile.filters.FiltersScreen
import app.omnivore.omnivore.feature.web.WebViewScreen
import app.omnivore.omnivore.navigation.Routes
import app.omnivore.omnivore.navigation.TopLevelDestination
@Composable
fun RootView(
loginViewModel: LoginViewModel,
searchViewModel: SearchViewModel,
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel,
loginViewModel: LoginViewModel = hiltViewModel()
) {
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
val snackbarHostState = remember { SnackbarHostState() }
val navController = rememberNavController()
val followingTabActive by loginViewModel.followingTabActiveState.collectAsStateWithLifecycle()
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
val destinations = if (followingTabActive) {
TopLevelDestination.entries
} else {
TopLevelDestination.entries.filter { it.route != Routes.Following.route }
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = {
if (navController.currentBackStackEntryAsState().value?.destination?.route in TopLevelDestination.entries.map { it.route }) {
OmnivoreBottomBar(
navController,
destinations,
navController.currentBackStackEntryAsState().value?.destination
)
}
}) { padding ->
Box(
modifier = if (!hasAuthToken) Modifier.background(Color(0xFFFCEBA8)) else Modifier
.fillMaxSize()
@ -61,16 +89,11 @@ fun RootView(
WindowInsetsSides.Horizontal,
),
)
){
) {
if (hasAuthToken) {
PrimaryNavigator(
loginViewModel = loginViewModel,
searchViewModel = searchViewModel,
labelsViewModel = labelsViewModel,
saveViewModel = saveViewModel,
editInfoViewModel = editInfoViewModel,
navController = navController,
snackbarHostState = snackbarHostState
)
} else {
WelcomeScreen(viewModel = loginViewModel)
@ -86,47 +109,51 @@ fun RootView(
}
}
private const val INITIAL_OFFSET_FACTOR = 0.10f
@Composable
fun PrimaryNavigator(
loginViewModel: LoginViewModel,
searchViewModel: SearchViewModel,
labelsViewModel: LabelsViewModel,
saveViewModel: SaveViewModel,
editInfoViewModel: EditInfoViewModel,
navController: NavHostController,
snackbarHostState: SnackbarHostState
) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Routes.Library.route
) {
composable(Routes.Library.route) {
LibraryView(
navController = navController,
labelsViewModel = labelsViewModel,
saveViewModel = saveViewModel,
editInfoViewModel = editInfoViewModel,
)
NavHost(navController = navController,
startDestination = Routes.Home.route,
enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) },
exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) },
popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) },
popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }) {
navigation(startDestination = Routes.Inbox.route,
route = Routes.Home.route,
enterTransition = { EnterTransition.None },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { ExitTransition.None }) {
composable(Routes.Inbox.route) {
LibraryView(navController = navController)
}
composable(Routes.Following.route) {
FollowingScreen(navController = navController)
}
composable(Routes.Settings.route) {
SettingsScreen(navController = navController)
}
}
composable(Routes.Search.route) {
SearchView(
viewModel = searchViewModel, navController = navController
)
}
composable(Routes.Settings.route) {
SettingsScreen(
loginViewModel = loginViewModel,
navController = navController
)
SearchView(navController = navController)
}
composable(Routes.About.route) {
AboutScreen(
navController = navController
)
AboutScreen(navController = navController)
}
composable(Routes.Filters.route) {
FiltersScreen(navController = navController)
}
composable(Routes.Account.route) {
@ -149,3 +176,49 @@ fun PrimaryNavigator(
}
}
}
@Composable
private fun OmnivoreBottomBar(
navController: NavHostController,
destinations: List<TopLevelDestination>,
currentDestination: NavDestination?
) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.background
) {
destinations.forEach { screen ->
val icon = if (screen.route == currentDestination?.route) {
ImageVector.vectorResource(id = screen.selectedIcon)
} else {
ImageVector.vectorResource(id = screen.unselectedIcon)
}
NavigationBarItem(icon = {
Icon(
icon, contentDescription = stringResource(id = screen.iconTextId)
)
},
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
})
}
}
}
private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) =
this?.hierarchy?.any {
it.route?.contains(destination.name, true) ?: false
} ?: false

View file

@ -8,19 +8,20 @@ import androidx.compose.ui.text.intl.Locale
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.DatastoreKeys
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.datastore.omnivoreAuthToken
import app.omnivore.omnivore.graphql.generated.SaveUrlMutation
import app.omnivore.omnivore.graphql.generated.type.SaveUrlInput
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.utils.Constants
import app.omnivore.omnivore.utils.ResourceProvider
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.*
import java.util.TimeZone
import java.util.UUID
import java.util.regex.Pattern
import javax.inject.Inject
@ -48,7 +49,7 @@ class SaveViewModel @Inject constructor(
private set
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
datastoreRepo.getString(omnivoreAuthToken)
}
/**

View file

@ -73,7 +73,7 @@ fun SavedItemCard(
modifier = Modifier
.weight(1f, fill = false)
.padding(end = 20.dp)
.defaultMinSize(minHeight = 55.dp)
.defaultMinSize(minHeight = 50.dp)
) {
ReadInfo(item = savedItem)

View file

@ -1,155 +0,0 @@
package app.omnivore.omnivore.feature.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
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.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.R
import app.omnivore.omnivore.feature.auth.LoginViewModel
import app.omnivore.omnivore.navigation.Routes
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun SettingsScreen(
loginViewModel: LoginViewModel,
navController: NavHostController
) {
Scaffold(topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_view_title)) },
navigationIcon = {
IconButton(onClick = { navController.navigateUp() }) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = null
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
),
)
}) { paddingValues ->
SettingsViewContent(
loginViewModel = loginViewModel,
navController = navController,
paddingValues = paddingValues
)
}
}
@Composable
fun SettingsViewContent(
loginViewModel: LoginViewModel,
navController: NavHostController,
paddingValues: PaddingValues
) {
val showLogoutDialog = remember { mutableStateOf(false) }
val state = rememberLazyListState()
LazyColumn(
state = state,
contentPadding = paddingValues,
) {
item {
SettingRow(title = stringResource(R.string.settings_view_setting_row_manage_account)) {
navController.navigate(Routes.Account.route)
}
}
item {
SettingRow(
title = stringResource(R.string.settings_view_setting_row_logout)
) {
showLogoutDialog.value = true
}
}
item {
SettingRow(title = stringResource(R.string.about_view_title)) {
navController.navigate(Routes.About.route)
}
}
}
if (showLogoutDialog.value) {
LogoutDialog { performLogout ->
if (performLogout) {
loginViewModel.logout()
}
showLogoutDialog.value = false
}
}
}
@Composable
internal fun SettingRow(
title: String, subtitle: String? = null, onClick: (() -> Unit)?
) {
Row(
modifier = Modifier
.clickable(enabled = onClick != null, onClick = { onClick?.invoke() })
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = SettingsVerticalPadding)
) {
Text(
modifier = Modifier.padding(horizontal = SettingsHorizontalPadding),
text = title,
overflow = TextOverflow.Ellipsis,
maxLines = 2,
style = MaterialTheme.typography.titleLarge,
fontSize = SettingsTitleFontSize,
)
if (!subtitle.isNullOrBlank()) {
Text(
text = subtitle,
modifier = Modifier
.padding(horizontal = SettingsHorizontalPadding)
.alpha(SettingsSecondaryItemAlpha),
style = MaterialTheme.typography.bodySmall,
maxLines = 10,
)
}
}
}
}
internal val SettingsHorizontalPadding = 16.dp
internal val SettingsVerticalPadding = 16.dp
internal const val SettingsSecondaryItemAlpha = .78f
internal val SettingsTitleFontSize = 16.sp
internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/releases"

View file

@ -1,13 +1,16 @@
package app.omnivore.omnivore.navigation
sealed class Routes(val route: String) {
object Library : Routes("Library")
object Settings : Routes("Settings")
object About : Routes("About")
object Account : Routes("Account")
object Search : Routes("Search")
object Documentation : Routes("Documentation")
object PrivacyPolicy : Routes("PrivacyPolicy")
object TermsAndConditions : Routes("TermsAndConditions")
object Notebook : Routes("Notebook")
data object Home : Routes("Home")
data object Following : Routes("Following")
data object Inbox : Routes("Inbox")
data object Settings : Routes("Settings")
data object About : Routes("About")
data object Filters : Routes("Filters")
data object Account : Routes("Account")
data object Search : Routes("Search")
data object Documentation : Routes("Documentation")
data object PrivacyPolicy : Routes("PrivacyPolicy")
data object TermsAndConditions : Routes("TermsAndConditions")
data object Notebook : Routes("Notebook")
}

View file

@ -0,0 +1,34 @@
package app.omnivore.omnivore.navigation
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.designsystem.icon.OmnivoreIcons
enum class TopLevelDestination(
val selectedIcon: Int,
val unselectedIcon: Int,
val iconTextId: Int,
val titleTextId: Int,
val route: String,
) {
FOLLOWING(
selectedIcon = OmnivoreIcons.Following,
unselectedIcon = OmnivoreIcons.FollowingEmpty,
iconTextId = R.string.following,
titleTextId = R.string.following,
route = Routes.Following.route
),
INBOX(
selectedIcon = OmnivoreIcons.Inbox,
unselectedIcon = OmnivoreIcons.InboxEmpty,
iconTextId = R.string.inbox,
titleTextId = R.string.inbox,
route = Routes.Inbox.route
),
PROFILE(
selectedIcon = OmnivoreIcons.Profile,
unselectedIcon = OmnivoreIcons.ProfileEmpty,
iconTextId = R.string.profile,
titleTextId = R.string.profile,
route = Routes.Settings.route
),
}

View file

@ -27,8 +27,8 @@ object DatastoreKeys {
}
object AppleConstants {
const val clientId = "app.omnivore"
const val redirectURI = BuildConfig.OMNIVORE_API_URL + "/api/mobile-auth/android-apple-redirect"
const val scope = "name%20email"
const val authUrl = "https://appleid.apple.com/auth/authorize"
const val clientId = "app.omnivore"
const val redirectURI = BuildConfig.OMNIVORE_API_URL + "/api/mobile-auth/android-apple-redirect"
const val scope = "name%20email"
const val authUrl = "https://appleid.apple.com/auth/authorize"
}

View file

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature
package app.omnivore.omnivore.utils
import android.content.Context
import androidx.annotation.StringRes

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M400,800L232,872Q192,889 156,865.5Q120,842 120,799L120,280Q120,247 143.5,223.5Q167,200 200,200L600,200Q633,200 656.5,223.5Q680,247 680,280L680,799Q680,842 644,865.5Q608,889 568,872L400,800ZM400,712L600,798Q600,798 600,798Q600,798 600,798L600,280Q600,280 600,280Q600,280 600,280L200,280Q200,280 200,280Q200,280 200,280L200,798Q200,798 200,798Q200,798 200,798L400,712ZM800,800Q783,800 771.5,788.5Q760,777 760,760L760,120Q760,120 760,120Q760,120 760,120L280,120Q263,120 251.5,108.5Q240,97 240,80Q240,63 251.5,51.5Q263,40 280,40L760,40Q793,40 816.5,63.5Q840,87 840,120L840,760Q840,777 828.5,788.5Q817,800 800,800ZM400,280L200,280Q200,280 200,280Q200,280 200,280L200,280Q200,280 200,280Q200,280 200,280L600,280Q600,280 600,280Q600,280 600,280L600,280Q600,280 600,280Q600,280 600,280L400,280Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M120,920L120,280Q120,247 143.5,223.5Q167,200 200,200L600,200Q633,200 656.5,223.5Q680,247 680,280L680,920L400,800L120,920ZM760,800L760,120Q760,120 760,120Q760,120 760,120L240,120L240,40L760,40Q793,40 816.5,63.5Q840,87 840,120L840,800L760,800Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,720L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Q207,800 183.5,776.5Q160,753 160,720ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,720L480,720Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,549Q470,549 460.5,546.5Q451,544 442,539L104,355Q93,349 88.5,340Q84,331 84,320Q84,309 88.5,300Q93,291 104,285L442,101Q451,96 460.5,93.5Q470,91 480,91Q490,91 499.5,93.5Q509,96 518,101L856,285Q867,291 871.5,300Q876,309 876,320Q876,331 871.5,340Q867,349 856,355L518,539Q509,544 499.5,546.5Q490,549 480,549ZM480,469L753,320L480,171L207,320L480,469ZM480,320L480,320L480,320L480,320L480,320ZM480,629L794,458Q796,457 813,453Q830,453 841.5,464.5Q853,476 853,493Q853,504 848,513Q843,522 832,528L518,699Q509,704 499.5,706.5Q490,709 480,709Q470,709 460.5,706.5Q451,704 442,699L128,528Q117,522 112,513Q107,504 107,493Q107,476 118.5,464.5Q130,453 147,453Q152,453 156.5,454.5Q161,456 166,458L480,629ZM480,789L794,618Q796,617 813,613Q830,613 841.5,624.5Q853,636 853,653Q853,664 848,673Q843,682 832,688L518,859Q509,864 499.5,866.5Q490,869 480,869Q470,869 460.5,866.5Q451,864 442,859L128,688Q117,682 112,673Q107,664 107,653Q107,636 118.5,624.5Q130,613 147,613Q152,613 156.5,614.5Q161,616 166,618L480,789Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,549Q470,549 460.5,546.5Q451,544 442,539L104,355Q93,349 88.5,340Q84,331 84,320Q84,309 88.5,300Q93,291 104,285L442,101Q451,96 460.5,93.5Q470,91 480,91Q490,91 499.5,93.5Q509,96 518,101L856,285Q867,291 871.5,300Q876,309 876,320Q876,331 871.5,340Q867,349 856,355L518,539Q509,544 499.5,546.5Q490,549 480,549ZM480,629L794,458Q796,457 813,453Q830,453 841.5,464.5Q853,476 853,493Q853,504 848,513Q843,522 832,528L518,699Q509,704 499.5,706.5Q490,709 480,709Q470,709 460.5,706.5Q451,704 442,699L128,528Q117,522 112,513Q107,504 107,493Q107,476 118.5,464.5Q130,453 147,453Q152,453 156.5,454.5Q161,456 166,458L480,629ZM480,789L794,618Q796,617 813,613Q830,613 841.5,624.5Q853,636 853,653Q853,664 848,673Q843,682 832,688L518,859Q509,864 499.5,866.5Q490,869 480,869Q470,869 460.5,866.5Q451,864 442,859L128,688Q117,682 112,673Q107,664 107,653Q107,636 118.5,624.5Q130,613 147,613Q152,613 156.5,614.5Q161,616 166,618L480,789Z"/>
</vector>

View file

@ -201,13 +201,13 @@
<string name="policy_webview_title">Einstellungen</string>
<!-- SettingsView -->
<string name="settings_view_title">Einstellungen</string>
<string name="settings_view_setting_row_documentation">Dokumentation</string>
<string name="settings_view_setting_row_feedback">Feedback</string>
<string name="settings_view_setting_row_privacy_policy">Datenschutzerklärung</string>
<string name="settings_view_setting_row_terms_and_conditions">Nutzungsbedingungen</string>
<string name="settings_view_setting_row_manage_account">Konto verwalten</string>
<string name="settings_view_setting_row_logout">Abmelden</string>
<string name="profile_view_title">Einstellungen</string>
<string name="about_documentation">Dokumentation</string>
<string name="about_feedback">Feedback</string>
<string name="about_privacy_policy">Datenschutzerklärung</string>
<string name="about_terms_and_conditions">Nutzungsbedingungen</string>
<string name="profile_manage_account">Konto verwalten</string>
<string name="about_logout">Abmelden</string>
<!-- AddLinkSheet -->
<string name="add_link_sheet_title">Link hinzufügen</string>
@ -231,4 +231,4 @@
<string name="edit_info_sheet_action_cancel">Abbrechen</string>
<string name="edit_info_sheet_error">Fehler beim Bearbeiten des Artikels!</string>
<string name="edit_info_sheet_success">Artikelinformationen erfolgreich aktualisiert!</string>
</resources>
</resources>

View file

@ -201,13 +201,13 @@
<string name="policy_webview_title">设定</string>
<!-- SettingsView -->
<string name="settings_view_title">设定</string>
<string name="settings_view_setting_row_documentation">文件</string>
<string name="settings_view_setting_row_feedback">反馈</string>
<string name="settings_view_setting_row_privacy_policy">隐私策略</string>
<string name="settings_view_setting_row_terms_and_conditions">条款和条件</string>
<string name="settings_view_setting_row_manage_account">管理帐户</string>
<string name="settings_view_setting_row_logout">登出</string>
<string name="profile_view_title">设定</string>
<string name="about_documentation">文件</string>
<string name="about_feedback">反馈</string>
<string name="about_privacy_policy">隐私策略</string>
<string name="about_terms_and_conditions">条款和条件</string>
<string name="profile_manage_account">管理帐户</string>
<string name="about_logout">登出</string>
<!-- stock filters -->
<string name="library_filter_inbox">收集箱</string>

View file

@ -200,11 +200,11 @@
<string name="policy_webview_title">設定</string>
<!-- SettingsView -->
<string name="settings_view_title">設定</string>
<string name="settings_view_setting_row_documentation">文件</string>
<string name="settings_view_setting_row_feedback">回饋</string>
<string name="settings_view_setting_row_privacy_policy">隱私政策</string>
<string name="settings_view_setting_row_terms_and_conditions">條款和條件</string>
<string name="settings_view_setting_row_manage_account">管理帳戶</string>
<string name="settings_view_setting_row_logout">登出</string>
<string name="profile_view_title">設定</string>
<string name="about_documentation">文件</string>
<string name="about_feedback">回饋</string>
<string name="about_privacy_policy">隱私政策</string>
<string name="about_terms_and_conditions">條款和條件</string>
<string name="profile_manage_account">管理帳戶</string>
<string name="about_logout">登出</string>
</resources>

View file

@ -14,6 +14,11 @@
<string name="pdf_highlight_menu_note">Note</string>
<string name="back">Back</string>
<!-- Navigation -->
<string name="following">Following</string>
<string name="inbox">Inbox</string>
<string name="profile">Profile</string>
<!-- Apple Auth -->
<string name="apple_auth_text">Continue with Apple</string>
<string name="apple_auth_loading">Signing in...</string>
@ -197,6 +202,9 @@
<string name="logout_dialog_action_confirm">Confirm</string>
<string name="logout_dialog_action_cancel">Cancel</string>
<!-- Filters -->
<string name="hide_following_tab">Hide following tab</string>
<!-- ManageAccount -->
<string name="manage_account_title">Manage Account</string>
<string name="manage_account_action_reset_data_cache">Reset Data Cache</string>
@ -205,13 +213,15 @@
<string name="policy_webview_title">Settings</string>
<!-- SettingsView -->
<string name="settings_view_title">Settings</string>
<string name="settings_view_setting_row_documentation">Documentation</string>
<string name="settings_view_setting_row_feedback">Feedback</string>
<string name="settings_view_setting_row_privacy_policy">Privacy Policy</string>
<string name="settings_view_setting_row_terms_and_conditions">Terms and Conditions</string>
<string name="settings_view_setting_row_manage_account">Manage Account</string>
<string name="settings_view_setting_row_logout">Logout</string>
<string name="profile_view_title">Profile</string>
<string name="about_documentation">Documentation</string>
<string name="about_feedback">Feedback</string>
<string name="about_privacy_policy">Privacy Policy</string>
<string name="about_terms_and_conditions">Terms and Conditions</string>
<string name="profile_filters">Filters</string>
<string name="profile_manage_account">Manage Account</string>
<string name="about_logout">Logout</string>
<!-- About -->
<string name="about_view_title">About</string>

View file

@ -1,40 +1,38 @@
[versions]
accompanistSystemUiController = "0.34.0"
accompanistFlowLayout = "0.32.0"
androidGradlePlugin = "8.2.2"
androidxActivity = "1.8.2"
accompanistFlowLayout = "0.34.0"
androidGradlePlugin = "8.3.2"
androidxActivity = "1.9.0"
androidxAppCompat = "1.6.1"
androidxComposeBom = "2024.02.01"
androidxComposeBom = "2024.04.01"
androidxComposeCompiler = "1.5.9"
androidxCore = "1.12.0"
androidxDataStore = "1.0.0"
androidxCore = "1.13.0"
androidxDataStore = "1.1.0"
androidxEspresso = "3.5.1"
androidxHiltNavigationCompose = "1.2.0"
androidxLifecycle = "2.7.0"
androidxNavigation = "2.7.7"
androidxSecurity = "1.0.0"
androidxTestExt = "1.1.5"
apollo = "3.8.2"
apollo = "3.8.3"
chiptextfieldM3 = "0.6.5"
coil = "2.5.0"
coil = "2.6.0"
composeMarkdown = "0.3.3"
coreSplashscreen = "1.0.1"
gson = "2.10.1"
hilt = "2.50"
hilt = "2.51"
intercom = "15.8.2"
junit4 = "4.13.2"
kotlin = "1.9.22"
ksp = "1.9.22-1.0.17"
kotlinxCoroutines = "1.7.3"
playServices = "18.3.0"
playServicesAuth = "21.0.0"
ksp = "1.9.22-1.0.18"
kotlinxCoroutines = "1.8.0"
playServices = "18.4.0"
playServicesAuth = "21.1.0"
posthog = "2.0.3"
pspdfkit = "8.9.1"
retrofit = "2.9.0"
retrofit = "2.11.0"
room = "2.6.1"
[libraries]
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanistSystemUiController" }
accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanistFlowLayout" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" }

View file

@ -1,6 +1,6 @@
#Wed Feb 14 01:21:10 GMT 2024
#Wed Apr 17 23:59:51 CEST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists