mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3871 from stefanosansone/feature/following-screen
Android - Following screen
This commit is contained in:
commit
44d3edc794
98 changed files with 5237 additions and 3515 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
query GetLabels {
|
||||
labels {
|
||||
... on LabelsSuccess {
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
labels {
|
||||
... on LabelsSuccess {
|
||||
labels {
|
||||
...LabelFields
|
||||
}
|
||||
}
|
||||
... on LabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
... on LabelsError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
query ValidateUsername($username: String!) {
|
||||
validateUsername(username: $username)
|
||||
validateUsername(username: $username)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, " +
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package app.omnivore.omnivore.core.designsystem.util
|
||||
|
||||
const val SecondaryItemAlpha = .78f
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = "")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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 = { },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package app.omnivore.omnivore.feature
|
||||
package app.omnivore.omnivore.utils
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue