diff --git a/android/Omnivore/app/build.gradle.kts b/android/Omnivore/app/build.gradle.kts index 435e866ea..e8363a80d 100644 --- a/android/Omnivore/app/build.gradle.kts +++ b/android/Omnivore/app/build.gradle.kts @@ -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 { diff --git a/android/Omnivore/app/src/main/graphql/ApplyLabels.graphql b/android/Omnivore/app/src/main/graphql/ApplyLabels.graphql index 0e9fe8cf0..54b4405a5 100644 --- a/android/Omnivore/app/src/main/graphql/ApplyLabels.graphql +++ b/android/Omnivore/app/src/main/graphql/ApplyLabels.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/ArchiveSavedItem.graphql b/android/Omnivore/app/src/main/graphql/ArchiveSavedItem.graphql index 7f0bff0a5..7e36a82b9 100644 --- a/android/Omnivore/app/src/main/graphql/ArchiveSavedItem.graphql +++ b/android/Omnivore/app/src/main/graphql/ArchiveSavedItem.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/ArticleContent.graphql b/android/Omnivore/app/src/main/graphql/ArticleContent.graphql index 3070ab101..fc7a2b788 100644 --- a/android/Omnivore/app/src/main/graphql/ArticleContent.graphql +++ b/android/Omnivore/app/src/main/graphql/ArticleContent.graphql @@ -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 } diff --git a/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql b/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql index d4417067d..e9717ac6a 100644 --- a/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql +++ b/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql @@ -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 + } } - } } diff --git a/android/Omnivore/app/src/main/graphql/CreateLabel.graphql b/android/Omnivore/app/src/main/graphql/CreateLabel.graphql index d3b438b2e..df5638235 100644 --- a/android/Omnivore/app/src/main/graphql/CreateLabel.graphql +++ b/android/Omnivore/app/src/main/graphql/CreateLabel.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql b/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql index e61011a3e..df2c4e8ab 100644 --- a/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql +++ b/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/DeleteSavedItem.graphql b/android/Omnivore/app/src/main/graphql/DeleteSavedItem.graphql index cabbc6a0d..0327ac373 100644 --- a/android/Omnivore/app/src/main/graphql/DeleteSavedItem.graphql +++ b/android/Omnivore/app/src/main/graphql/DeleteSavedItem.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/Labels.graphql b/android/Omnivore/app/src/main/graphql/Labels.graphql index d61a6c868..cc6aaf202 100644 --- a/android/Omnivore/app/src/main/graphql/Labels.graphql +++ b/android/Omnivore/app/src/main/graphql/Labels.graphql @@ -1,12 +1,12 @@ query GetLabels { - labels { - ... on LabelsSuccess { - labels { - ...LabelFields - } + labels { + ... on LabelsSuccess { + labels { + ...LabelFields + } + } + ... on LabelsError { + errorCodes + } } - ... on LabelsError { - errorCodes - } - } } diff --git a/android/Omnivore/app/src/main/graphql/MergeHighlight.graphql b/android/Omnivore/app/src/main/graphql/MergeHighlight.graphql index 7765de8d7..6bc25fd86 100644 --- a/android/Omnivore/app/src/main/graphql/MergeHighlight.graphql +++ b/android/Omnivore/app/src/main/graphql/MergeHighlight.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql b/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql index 80a30eea1..275272894 100644 --- a/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql +++ b/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/SaveUrl.graphql b/android/Omnivore/app/src/main/graphql/SaveUrl.graphql index 964e2b332..5f08fc990 100644 --- a/android/Omnivore/app/src/main/graphql/SaveUrl.graphql +++ b/android/Omnivore/app/src/main/graphql/SaveUrl.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/Search.graphql b/android/Omnivore/app/src/main/graphql/Search.graphql index a626a9e09..595d8324f 100644 --- a/android/Omnivore/app/src/main/graphql/Search.graphql +++ b/android/Omnivore/app/src/main/graphql/Search.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/TypeAheadSearch.graphql b/android/Omnivore/app/src/main/graphql/TypeAheadSearch.graphql index 489ec923a..35d8d0ceb 100644 --- a/android/Omnivore/app/src/main/graphql/TypeAheadSearch.graphql +++ b/android/Omnivore/app/src/main/graphql/TypeAheadSearch.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql b/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql index 18f93dcea..1a2b69eb9 100644 --- a/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql +++ b/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql @@ -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 + } } - } } diff --git a/android/Omnivore/app/src/main/graphql/UpdatePage.graphql b/android/Omnivore/app/src/main/graphql/UpdatePage.graphql index acb8f248f..3aca4ae64 100644 --- a/android/Omnivore/app/src/main/graphql/UpdatePage.graphql +++ b/android/Omnivore/app/src/main/graphql/UpdatePage.graphql @@ -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 + } } - } } diff --git a/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql b/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql index 8a2c29013..8055d2199 100644 --- a/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql +++ b/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql @@ -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 - } - } } diff --git a/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql b/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql index 79938a855..47aafc8a1 100644 --- a/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql +++ b/android/Omnivore/app/src/main/graphql/ValidateUsername.graphql @@ -1,3 +1,3 @@ query ValidateUsername($username: String!) { - validateUsername(username: $username) + validateUsername(username: $username) } diff --git a/android/Omnivore/app/src/main/graphql/Viewer.graphql b/android/Omnivore/app/src/main/graphql/Viewer.graphql index 1430d4e6f..05c493af2 100644 --- a/android/Omnivore/app/src/main/graphql/Viewer.graphql +++ b/android/Omnivore/app/src/main/graphql/Viewer.graphql @@ -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 + } } - } } diff --git a/android/Omnivore/app/src/main/graphql/schema.graphqls b/android/Omnivore/app/src/main/graphql/schema.graphqls index 5c2431d8a..9bf3f87e6 100644 --- a/android/Omnivore/app/src/main/graphql/schema.graphqls +++ b/android/Omnivore/app/src/main/graphql/schema.graphqls @@ -1,2823 +1,2823 @@ directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION type AddPopularReadError { - errorCodes: [AddPopularReadErrorCode!]! + errorCodes: [AddPopularReadErrorCode!]! } enum AddPopularReadErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union AddPopularReadResult = AddPopularReadError | AddPopularReadSuccess type AddPopularReadSuccess { - pageId: String! + pageId: String! } type ApiKey { - createdAt: Date! - expiresAt: Date! - id: ID! - key: String - name: String! - scopes: [String!] - usedAt: Date + createdAt: Date! + expiresAt: Date! + id: ID! + key: String + name: String! + scopes: [String!] + usedAt: Date } type ApiKeysError { - errorCodes: [ApiKeysErrorCode!]! + errorCodes: [ApiKeysErrorCode!]! } enum ApiKeysErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union ApiKeysResult = ApiKeysError | ApiKeysSuccess type ApiKeysSuccess { - apiKeys: [ApiKey!]! + apiKeys: [ApiKey!]! } type ArchiveLinkError { - errorCodes: [ArchiveLinkErrorCode!]! - message: String! + errorCodes: [ArchiveLinkErrorCode!]! + message: String! } enum ArchiveLinkErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } input ArchiveLinkInput { - archived: Boolean! - linkId: ID! + archived: Boolean! + linkId: ID! } union ArchiveLinkResult = ArchiveLinkError | ArchiveLinkSuccess type ArchiveLinkSuccess { - linkId: String! - message: String! + linkId: String! + message: String! } type Article { - author: String - content: String! - contentReader: ContentReader! - createdAt: Date! - description: String - folder: String! - hasContent: Boolean - hash: String! - highlights(input: ArticleHighlightsInput): [Highlight!]! - id: ID! - image: String - isArchived: Boolean! - labels: [Label!] - language: String - linkId: ID - originalArticleUrl: String - originalHtml: String - pageType: PageType - postedByViewer: Boolean - publishedAt: Date - readAt: Date - readingProgressAnchorIndex: Int! - readingProgressPercent: Float! - readingProgressTopPercent: Float - recommendations: [Recommendation!] - savedAt: Date! - savedByViewer: Boolean - shareInfo: LinkShareInfo - sharedComment: String - siteIcon: String - siteName: String - slug: String! - state: ArticleSavingRequestStatus - subscription: String - title: String! - unsubHttpUrl: String - unsubMailTo: String - updatedAt: Date - uploadFileId: ID - url: String! - wordsCount: Int + author: String + content: String! + contentReader: ContentReader! + createdAt: Date! + description: String + folder: String! + hasContent: Boolean + hash: String! + highlights(input: ArticleHighlightsInput): [Highlight!]! + id: ID! + image: String + isArchived: Boolean! + labels: [Label!] + language: String + linkId: ID + originalArticleUrl: String + originalHtml: String + pageType: PageType + postedByViewer: Boolean + publishedAt: Date + readAt: Date + readingProgressAnchorIndex: Int! + readingProgressPercent: Float! + readingProgressTopPercent: Float + recommendations: [Recommendation!] + savedAt: Date! + savedByViewer: Boolean + shareInfo: LinkShareInfo + sharedComment: String + siteIcon: String + siteName: String + slug: String! + state: ArticleSavingRequestStatus + subscription: String + title: String! + unsubHttpUrl: String + unsubMailTo: String + updatedAt: Date + uploadFileId: ID + url: String! + wordsCount: Int } type ArticleEdge { - cursor: String! - node: Article! + cursor: String! + node: Article! } type ArticleError { - errorCodes: [ArticleErrorCode!]! + errorCodes: [ArticleErrorCode!]! } enum ArticleErrorCode { - BAD_DATA - NOT_FOUND - UNAUTHORIZED + BAD_DATA + NOT_FOUND + UNAUTHORIZED } input ArticleHighlightsInput { - includeFriends: Boolean + includeFriends: Boolean } union ArticleResult = ArticleError | ArticleSuccess type ArticleSavingRequest { - article: Article @deprecated(reason: "article has been replaced with slug") - createdAt: Date! - errorCode: CreateArticleErrorCode - id: ID! - slug: String! - status: ArticleSavingRequestStatus! - updatedAt: Date - url: String! - user: User! - userId: ID! @deprecated(reason: "userId has been replaced with user") + article: Article @deprecated(reason: "article has been replaced with slug") + createdAt: Date! + errorCode: CreateArticleErrorCode + id: ID! + slug: String! + status: ArticleSavingRequestStatus! + updatedAt: Date + url: String! + user: User! + userId: ID! @deprecated(reason: "userId has been replaced with user") } type ArticleSavingRequestError { - errorCodes: [ArticleSavingRequestErrorCode!]! + errorCodes: [ArticleSavingRequestErrorCode!]! } enum ArticleSavingRequestErrorCode { - BAD_DATA - NOT_FOUND - UNAUTHORIZED + BAD_DATA + NOT_FOUND + UNAUTHORIZED } union ArticleSavingRequestResult = ArticleSavingRequestError | ArticleSavingRequestSuccess enum ArticleSavingRequestStatus { - ARCHIVED - CONTENT_NOT_FETCHED - DELETED - FAILED - PROCESSING - SUCCEEDED + ARCHIVED + CONTENT_NOT_FETCHED + DELETED + FAILED + PROCESSING + SUCCEEDED } type ArticleSavingRequestSuccess { - articleSavingRequest: ArticleSavingRequest! + articleSavingRequest: ArticleSavingRequest! } type ArticleSuccess { - article: Article! + article: Article! } type ArticlesError { - errorCodes: [ArticlesErrorCode!]! + errorCodes: [ArticlesErrorCode!]! } enum ArticlesErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union ArticlesResult = ArticlesError | ArticlesSuccess type ArticlesSuccess { - edges: [ArticleEdge!]! - pageInfo: PageInfo! + edges: [ArticleEdge!]! + pageInfo: PageInfo! } type BulkActionError { - errorCodes: [BulkActionErrorCode!]! + errorCodes: [BulkActionErrorCode!]! } enum BulkActionErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union BulkActionResult = BulkActionError | BulkActionSuccess type BulkActionSuccess { - success: Boolean! + success: Boolean! } enum BulkActionType { - ADD_LABELS - ARCHIVE - DELETE - MARK_AS_READ - MOVE_TO_FOLDER + ADD_LABELS + ARCHIVE + DELETE + MARK_AS_READ + MOVE_TO_FOLDER } enum ContentReader { - EPUB - PDF - WEB + EPUB + PDF + WEB } type CreateArticleError { - errorCodes: [CreateArticleErrorCode!]! + errorCodes: [CreateArticleErrorCode!]! } enum CreateArticleErrorCode { - ELASTIC_ERROR - NOT_ALLOWED_TO_PARSE - PAYLOAD_TOO_LARGE - UNABLE_TO_FETCH - UNABLE_TO_PARSE - UNAUTHORIZED - UPLOAD_FILE_MISSING + ELASTIC_ERROR + NOT_ALLOWED_TO_PARSE + PAYLOAD_TOO_LARGE + UNABLE_TO_FETCH + UNABLE_TO_PARSE + UNAUTHORIZED + UPLOAD_FILE_MISSING } input CreateArticleInput { - articleSavingRequestId: ID - folder: String - labels: [CreateLabelInput!] - preparedDocument: PreparedDocumentInput - publishedAt: Date - rssFeedUrl: String - savedAt: Date - skipParsing: Boolean - source: String - state: ArticleSavingRequestStatus - uploadFileId: ID - url: String! + articleSavingRequestId: ID + folder: String + labels: [CreateLabelInput!] + preparedDocument: PreparedDocumentInput + publishedAt: Date + rssFeedUrl: String + savedAt: Date + skipParsing: Boolean + source: String + state: ArticleSavingRequestStatus + uploadFileId: ID + url: String! } union CreateArticleResult = CreateArticleError | CreateArticleSuccess type CreateArticleSavingRequestError { - errorCodes: [CreateArticleSavingRequestErrorCode!]! + errorCodes: [CreateArticleSavingRequestErrorCode!]! } enum CreateArticleSavingRequestErrorCode { - BAD_DATA - UNAUTHORIZED + BAD_DATA + UNAUTHORIZED } input CreateArticleSavingRequestInput { - url: String! + url: String! } union CreateArticleSavingRequestResult = CreateArticleSavingRequestError | CreateArticleSavingRequestSuccess type CreateArticleSavingRequestSuccess { - articleSavingRequest: ArticleSavingRequest! + articleSavingRequest: ArticleSavingRequest! } type CreateArticleSuccess { - created: Boolean! - createdArticle: Article! - user: User! + created: Boolean! + createdArticle: Article! + user: User! } type CreateGroupError { - errorCodes: [CreateGroupErrorCode!]! + errorCodes: [CreateGroupErrorCode!]! } enum CreateGroupErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } input CreateGroupInput { - description: String - expiresInDays: Int - maxMembers: Int - name: String! - onlyAdminCanPost: Boolean - onlyAdminCanSeeMembers: Boolean - topics: [String!] + description: String + expiresInDays: Int + maxMembers: Int + name: String! + onlyAdminCanPost: Boolean + onlyAdminCanSeeMembers: Boolean + topics: [String!] } union CreateGroupResult = CreateGroupError | CreateGroupSuccess type CreateGroupSuccess { - group: RecommendationGroup! + group: RecommendationGroup! } type CreateHighlightError { - errorCodes: [CreateHighlightErrorCode!]! + errorCodes: [CreateHighlightErrorCode!]! } enum CreateHighlightErrorCode { - ALREADY_EXISTS - BAD_DATA - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + ALREADY_EXISTS + BAD_DATA + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input CreateHighlightInput { - annotation: String - articleId: ID! - color: String - highlightPositionAnchorIndex: Int - highlightPositionPercent: Float - html: String - id: ID! - patch: String - prefix: String - quote: String - sharedAt: Date - shortId: String! - suffix: String - type: HighlightType + annotation: String + articleId: ID! + color: String + highlightPositionAnchorIndex: Int + highlightPositionPercent: Float + html: String + id: ID! + patch: String + prefix: String + quote: String + sharedAt: Date + shortId: String! + suffix: String + type: HighlightType } type CreateHighlightReplyError { - errorCodes: [CreateHighlightReplyErrorCode!]! + errorCodes: [CreateHighlightReplyErrorCode!]! } enum CreateHighlightReplyErrorCode { - EMPTY_ANNOTATION - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + EMPTY_ANNOTATION + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input CreateHighlightReplyInput { - highlightId: ID! - text: String! + highlightId: ID! + text: String! } union CreateHighlightReplyResult = CreateHighlightReplyError | CreateHighlightReplySuccess type CreateHighlightReplySuccess { - highlightReply: HighlightReply! + highlightReply: HighlightReply! } union CreateHighlightResult = CreateHighlightError | CreateHighlightSuccess type CreateHighlightSuccess { - highlight: Highlight! + highlight: Highlight! } type CreateLabelError { - errorCodes: [CreateLabelErrorCode!]! + errorCodes: [CreateLabelErrorCode!]! } enum CreateLabelErrorCode { - BAD_REQUEST - LABEL_ALREADY_EXISTS - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + LABEL_ALREADY_EXISTS + NOT_FOUND + UNAUTHORIZED } input CreateLabelInput { - color: String - description: String - name: String! + color: String + description: String + name: String! } union CreateLabelResult = CreateLabelError | CreateLabelSuccess type CreateLabelSuccess { - label: Label! + label: Label! } type CreateNewsletterEmailError { - errorCodes: [CreateNewsletterEmailErrorCode!]! + errorCodes: [CreateNewsletterEmailErrorCode!]! } enum CreateNewsletterEmailErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } input CreateNewsletterEmailInput { - description: String - folder: String - name: String + description: String + folder: String + name: String } union CreateNewsletterEmailResult = CreateNewsletterEmailError | CreateNewsletterEmailSuccess type CreateNewsletterEmailSuccess { - newsletterEmail: NewsletterEmail! + newsletterEmail: NewsletterEmail! } type CreateReactionError { - errorCodes: [CreateReactionErrorCode!]! + errorCodes: [CreateReactionErrorCode!]! } enum CreateReactionErrorCode { - BAD_CODE - BAD_TARGET - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + BAD_CODE + BAD_TARGET + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input CreateReactionInput { - code: ReactionType! - highlightId: ID - userArticleId: ID + code: ReactionType! + highlightId: ID + userArticleId: ID } union CreateReactionResult = CreateReactionError | CreateReactionSuccess type CreateReactionSuccess { - reaction: Reaction! + reaction: Reaction! } type CreateReminderError { - errorCodes: [CreateReminderErrorCode!]! + errorCodes: [CreateReminderErrorCode!]! } enum CreateReminderErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input CreateReminderInput { - archiveUntil: Boolean! - clientRequestId: ID - linkId: ID - remindAt: Date! - sendNotification: Boolean! + archiveUntil: Boolean! + clientRequestId: ID + linkId: ID + remindAt: Date! + sendNotification: Boolean! } union CreateReminderResult = CreateReminderError | CreateReminderSuccess type CreateReminderSuccess { - reminder: Reminder! + reminder: Reminder! } scalar Date type DeleteAccountError { - errorCodes: [DeleteAccountErrorCode!]! + errorCodes: [DeleteAccountErrorCode!]! } enum DeleteAccountErrorCode { - FORBIDDEN - UNAUTHORIZED - USER_NOT_FOUND + FORBIDDEN + UNAUTHORIZED + USER_NOT_FOUND } union DeleteAccountResult = DeleteAccountError | DeleteAccountSuccess type DeleteAccountSuccess { - userID: ID! + userID: ID! } type DeleteFilterError { - errorCodes: [DeleteFilterErrorCode!]! + errorCodes: [DeleteFilterErrorCode!]! } enum DeleteFilterErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union DeleteFilterResult = DeleteFilterError | DeleteFilterSuccess type DeleteFilterSuccess { - filter: Filter! + filter: Filter! } type DeleteHighlightError { - errorCodes: [DeleteHighlightErrorCode!]! + errorCodes: [DeleteHighlightErrorCode!]! } enum DeleteHighlightErrorCode { - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } type DeleteHighlightReplyError { - errorCodes: [DeleteHighlightReplyErrorCode!]! + errorCodes: [DeleteHighlightReplyErrorCode!]! } enum DeleteHighlightReplyErrorCode { - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } union DeleteHighlightReplyResult = DeleteHighlightReplyError | DeleteHighlightReplySuccess type DeleteHighlightReplySuccess { - highlightReply: HighlightReply! + highlightReply: HighlightReply! } union DeleteHighlightResult = DeleteHighlightError | DeleteHighlightSuccess type DeleteHighlightSuccess { - highlight: Highlight! + highlight: Highlight! } type DeleteIntegrationError { - errorCodes: [DeleteIntegrationErrorCode!]! + errorCodes: [DeleteIntegrationErrorCode!]! } enum DeleteIntegrationErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union DeleteIntegrationResult = DeleteIntegrationError | DeleteIntegrationSuccess type DeleteIntegrationSuccess { - integration: Integration! + integration: Integration! } type DeleteLabelError { - errorCodes: [DeleteLabelErrorCode!]! + errorCodes: [DeleteLabelErrorCode!]! } enum DeleteLabelErrorCode { - BAD_REQUEST - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } union DeleteLabelResult = DeleteLabelError | DeleteLabelSuccess type DeleteLabelSuccess { - label: Label! + label: Label! } type DeleteNewsletterEmailError { - errorCodes: [DeleteNewsletterEmailErrorCode!]! + errorCodes: [DeleteNewsletterEmailErrorCode!]! } enum DeleteNewsletterEmailErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union DeleteNewsletterEmailResult = DeleteNewsletterEmailError | DeleteNewsletterEmailSuccess type DeleteNewsletterEmailSuccess { - newsletterEmail: NewsletterEmail! + newsletterEmail: NewsletterEmail! } type DeleteReactionError { - errorCodes: [DeleteReactionErrorCode!]! + errorCodes: [DeleteReactionErrorCode!]! } enum DeleteReactionErrorCode { - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } union DeleteReactionResult = DeleteReactionError | DeleteReactionSuccess type DeleteReactionSuccess { - reaction: Reaction! + reaction: Reaction! } type DeleteReminderError { - errorCodes: [DeleteReminderErrorCode!]! + errorCodes: [DeleteReminderErrorCode!]! } enum DeleteReminderErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union DeleteReminderResult = DeleteReminderError | DeleteReminderSuccess type DeleteReminderSuccess { - reminder: Reminder! + reminder: Reminder! } type DeleteRuleError { - errorCodes: [DeleteRuleErrorCode!]! + errorCodes: [DeleteRuleErrorCode!]! } enum DeleteRuleErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union DeleteRuleResult = DeleteRuleError | DeleteRuleSuccess type DeleteRuleSuccess { - rule: Rule! + rule: Rule! } type DeleteWebhookError { - errorCodes: [DeleteWebhookErrorCode!]! + errorCodes: [DeleteWebhookErrorCode!]! } enum DeleteWebhookErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union DeleteWebhookResult = DeleteWebhookError | DeleteWebhookSuccess type DeleteWebhookSuccess { - webhook: Webhook! + webhook: Webhook! } type DeviceToken { - createdAt: Date! - id: ID! - token: String! + createdAt: Date! + id: ID! + token: String! } type DeviceTokensError { - errorCodes: [DeviceTokensErrorCode!]! + errorCodes: [DeviceTokensErrorCode!]! } enum DeviceTokensErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union DeviceTokensResult = DeviceTokensError | DeviceTokensSuccess type DeviceTokensSuccess { - deviceTokens: [DeviceToken!]! + deviceTokens: [DeviceToken!]! } type EmptyTrashError { - errorCodes: [EmptyTrashErrorCode!]! + errorCodes: [EmptyTrashErrorCode!]! } enum EmptyTrashErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union EmptyTrashResult = EmptyTrashError | EmptyTrashSuccess type EmptyTrashSuccess { - success: Boolean + success: Boolean } type Feature { - createdAt: Date! - expiresAt: Date - grantedAt: Date - id: ID! - name: String! - token: String! - updatedAt: Date + createdAt: Date! + expiresAt: Date + grantedAt: Date + id: ID! + name: String! + token: String! + updatedAt: Date } type Feed { - author: String - createdAt: Date - description: String - id: ID - image: String - publishedAt: Date - title: String! - type: String - updatedAt: Date - url: String! + author: String + createdAt: Date + description: String + id: ID + image: String + publishedAt: Date + title: String! + type: String + updatedAt: Date + url: String! } type FeedArticle { - annotationsCount: Int - article: Article! - highlight: Highlight - highlightsCount: Int - id: ID! - reactions: [Reaction!]! - sharedAt: Date! - sharedBy: User! - sharedComment: String - sharedWithHighlights: Boolean + annotationsCount: Int + article: Article! + highlight: Highlight + highlightsCount: Int + id: ID! + reactions: [Reaction!]! + sharedAt: Date! + sharedBy: User! + sharedComment: String + sharedWithHighlights: Boolean } type FeedArticleEdge { - cursor: String! - node: FeedArticle! + cursor: String! + node: FeedArticle! } type FeedArticlesError { - errorCodes: [FeedArticlesErrorCode!]! + errorCodes: [FeedArticlesErrorCode!]! } enum FeedArticlesErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union FeedArticlesResult = FeedArticlesError | FeedArticlesSuccess type FeedArticlesSuccess { - edges: [FeedArticleEdge!]! - pageInfo: PageInfo! + edges: [FeedArticleEdge!]! + pageInfo: PageInfo! } type FeedEdge { - cursor: String! - node: Feed! + cursor: String! + node: Feed! } type FeedsError { - errorCodes: [FeedsErrorCode!]! + errorCodes: [FeedsErrorCode!]! } enum FeedsErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } input FeedsInput { - after: String - first: Int - query: String - sort: SortParams + after: String + first: Int + query: String + sort: SortParams } union FeedsResult = FeedsError | FeedsSuccess type FeedsSuccess { - edges: [FeedEdge!]! - pageInfo: PageInfo! + edges: [FeedEdge!]! + pageInfo: PageInfo! } type FetchContentError { - errorCodes: [FetchContentErrorCode!]! + errorCodes: [FetchContentErrorCode!]! } enum FetchContentErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union FetchContentResult = FetchContentError | FetchContentSuccess type FetchContentSuccess { - success: Boolean! + success: Boolean! } type Filter { - category: String - createdAt: Date! - defaultFilter: Boolean - description: String - filter: String! - folder: String - id: ID! - name: String! - position: Int! - updatedAt: Date - visible: Boolean + category: String + createdAt: Date! + defaultFilter: Boolean + description: String + filter: String! + folder: String + id: ID! + name: String! + position: Int! + updatedAt: Date + visible: Boolean } type FiltersError { - errorCodes: [FiltersErrorCode!]! + errorCodes: [FiltersErrorCode!]! } enum FiltersErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union FiltersResult = FiltersError | FiltersSuccess type FiltersSuccess { - filters: [Filter!]! + filters: [Filter!]! } type GenerateApiKeyError { - errorCodes: [GenerateApiKeyErrorCode!]! + errorCodes: [GenerateApiKeyErrorCode!]! } enum GenerateApiKeyErrorCode { - ALREADY_EXISTS - BAD_REQUEST - UNAUTHORIZED + ALREADY_EXISTS + BAD_REQUEST + UNAUTHORIZED } input GenerateApiKeyInput { - expiresAt: Date! - name: String! - scopes: [String!] + expiresAt: Date! + name: String! + scopes: [String!] } union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess type GenerateApiKeySuccess { - apiKey: ApiKey! + apiKey: ApiKey! } type GetFollowersError { - errorCodes: [GetFollowersErrorCode!]! + errorCodes: [GetFollowersErrorCode!]! } enum GetFollowersErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union GetFollowersResult = GetFollowersError | GetFollowersSuccess type GetFollowersSuccess { - followers: [User!]! + followers: [User!]! } type GetFollowingError { - errorCodes: [GetFollowingErrorCode!]! + errorCodes: [GetFollowingErrorCode!]! } enum GetFollowingErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union GetFollowingResult = GetFollowingError | GetFollowingSuccess type GetFollowingSuccess { - following: [User!]! + following: [User!]! } type GetUserPersonalizationError { - errorCodes: [GetUserPersonalizationErrorCode!]! + errorCodes: [GetUserPersonalizationErrorCode!]! } enum GetUserPersonalizationErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union GetUserPersonalizationResult = GetUserPersonalizationError | GetUserPersonalizationSuccess type GetUserPersonalizationSuccess { - userPersonalization: UserPersonalization + userPersonalization: UserPersonalization } input GoogleLoginInput { - email: String! - secret: String! + email: String! + secret: String! } type GoogleSignupError { - errorCodes: [SignupErrorCode]! + errorCodes: [SignupErrorCode]! } input GoogleSignupInput { - bio: String - email: String! - name: String! - pictureUrl: String! - secret: String! - sourceUserId: String! - username: String! + bio: String + email: String! + name: String! + pictureUrl: String! + secret: String! + sourceUserId: String! + username: String! } union GoogleSignupResult = GoogleSignupError | GoogleSignupSuccess type GoogleSignupSuccess { - me: User! + me: User! } type GroupsError { - errorCodes: [GroupsErrorCode!]! + errorCodes: [GroupsErrorCode!]! } enum GroupsErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union GroupsResult = GroupsError | GroupsSuccess type GroupsSuccess { - groups: [RecommendationGroup!]! + groups: [RecommendationGroup!]! } type Highlight { - annotation: String - color: String - createdAt: Date! - createdByMe: Boolean! - highlightPositionAnchorIndex: Int - highlightPositionPercent: Float - html: String - id: ID! - labels: [Label!] - patch: String - prefix: String - quote: String - reactions: [Reaction!]! - replies: [HighlightReply!]! - sharedAt: Date - shortId: String! - suffix: String - type: HighlightType! - updatedAt: Date - user: User! + annotation: String + color: String + createdAt: Date! + createdByMe: Boolean! + highlightPositionAnchorIndex: Int + highlightPositionPercent: Float + html: String + id: ID! + labels: [Label!] + patch: String + prefix: String + quote: String + reactions: [Reaction!]! + replies: [HighlightReply!]! + sharedAt: Date + shortId: String! + suffix: String + type: HighlightType! + updatedAt: Date + user: User! } type HighlightReply { - createdAt: Date! - highlight: Highlight! - id: ID! - text: String! - updatedAt: Date - user: User! + createdAt: Date! + highlight: Highlight! + id: ID! + text: String! + updatedAt: Date + user: User! } type HighlightStats { - highlightCount: Int! + highlightCount: Int! } enum HighlightType { - HIGHLIGHT - NOTE - REDACTION + HIGHLIGHT + NOTE + REDACTION } type ImportFromIntegrationError { - errorCodes: [ImportFromIntegrationErrorCode!]! + errorCodes: [ImportFromIntegrationErrorCode!]! } enum ImportFromIntegrationErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union ImportFromIntegrationResult = ImportFromIntegrationError | ImportFromIntegrationSuccess type ImportFromIntegrationSuccess { - success: Boolean! + success: Boolean! } enum ImportItemState { - ALL - ARCHIVED - UNARCHIVED - UNREAD + ALL + ARCHIVED + UNARCHIVED + UNREAD } type Integration { - createdAt: Date! - enabled: Boolean! - id: ID! - name: String! - taskName: String - token: String! - type: IntegrationType! - updatedAt: Date + createdAt: Date! + enabled: Boolean! + id: ID! + name: String! + taskName: String + token: String! + type: IntegrationType! + updatedAt: Date } enum IntegrationType { - EXPORT - IMPORT + EXPORT + IMPORT } type IntegrationsError { - errorCodes: [IntegrationsErrorCode!]! + errorCodes: [IntegrationsErrorCode!]! } enum IntegrationsErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union IntegrationsResult = IntegrationsError | IntegrationsSuccess type IntegrationsSuccess { - integrations: [Integration!]! + integrations: [Integration!]! } scalar JSON type JoinGroupError { - errorCodes: [JoinGroupErrorCode!]! + errorCodes: [JoinGroupErrorCode!]! } enum JoinGroupErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union JoinGroupResult = JoinGroupError | JoinGroupSuccess type JoinGroupSuccess { - group: RecommendationGroup! + group: RecommendationGroup! } type Label { - color: String! - createdAt: Date - description: String - id: ID! - internal: Boolean - name: String! - position: Int - source: String + color: String! + createdAt: Date + description: String + id: ID! + internal: Boolean + name: String! + position: Int + source: String } type LabelsError { - errorCodes: [LabelsErrorCode!]! + errorCodes: [LabelsErrorCode!]! } enum LabelsErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union LabelsResult = LabelsError | LabelsSuccess type LabelsSuccess { - labels: [Label!]! + labels: [Label!]! } type LeaveGroupError { - errorCodes: [LeaveGroupErrorCode!]! + errorCodes: [LeaveGroupErrorCode!]! } enum LeaveGroupErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union LeaveGroupResult = LeaveGroupError | LeaveGroupSuccess type LeaveGroupSuccess { - success: Boolean! + success: Boolean! } type Link { - highlightStats: HighlightStats! - id: ID! - page: Page! - postedByViewer: Boolean! - readState: ReadState! - savedAt: Date! - savedBy: User! - savedByViewer: Boolean! - shareInfo: LinkShareInfo! - shareStats: ShareStats! - slug: String! - updatedAt: Date - url: String! + highlightStats: HighlightStats! + id: ID! + page: Page! + postedByViewer: Boolean! + readState: ReadState! + savedAt: Date! + savedBy: User! + savedByViewer: Boolean! + shareInfo: LinkShareInfo! + shareStats: ShareStats! + slug: String! + updatedAt: Date + url: String! } type LinkShareInfo { - description: String! - imageUrl: String! - title: String! + description: String! + imageUrl: String! + title: String! } type LogOutError { - errorCodes: [LogOutErrorCode!]! + errorCodes: [LogOutErrorCode!]! } enum LogOutErrorCode { - LOG_OUT_FAILED + LOG_OUT_FAILED } union LogOutResult = LogOutError | LogOutSuccess type LogOutSuccess { - message: String + message: String } type LoginError { - errorCodes: [LoginErrorCode!]! + errorCodes: [LoginErrorCode!]! } enum LoginErrorCode { - ACCESS_DENIED - AUTH_FAILED - INVALID_CREDENTIALS - USER_ALREADY_EXISTS - USER_NOT_FOUND - WRONG_SOURCE + ACCESS_DENIED + AUTH_FAILED + INVALID_CREDENTIALS + USER_ALREADY_EXISTS + USER_NOT_FOUND + WRONG_SOURCE } union LoginResult = LoginError | LoginSuccess type LoginSuccess { - me: User! + me: User! } type MarkEmailAsItemError { - errorCodes: [MarkEmailAsItemErrorCode!]! + errorCodes: [MarkEmailAsItemErrorCode!]! } enum MarkEmailAsItemErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union MarkEmailAsItemResult = MarkEmailAsItemError | MarkEmailAsItemSuccess type MarkEmailAsItemSuccess { - success: Boolean! + success: Boolean! } type MergeHighlightError { - errorCodes: [MergeHighlightErrorCode!]! + errorCodes: [MergeHighlightErrorCode!]! } enum MergeHighlightErrorCode { - ALREADY_EXISTS - BAD_DATA - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + ALREADY_EXISTS + BAD_DATA + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input MergeHighlightInput { - annotation: String - articleId: ID! - color: String - highlightPositionAnchorIndex: Int - highlightPositionPercent: Float - html: String - id: ID! - overlapHighlightIdList: [String!]! - patch: String! - prefix: String - quote: String! - shortId: ID! - suffix: String + annotation: String + articleId: ID! + color: String + highlightPositionAnchorIndex: Int + highlightPositionPercent: Float + html: String + id: ID! + overlapHighlightIdList: [String!]! + patch: String! + prefix: String + quote: String! + shortId: ID! + suffix: String } union MergeHighlightResult = MergeHighlightError | MergeHighlightSuccess type MergeHighlightSuccess { - highlight: Highlight! - overlapHighlightIdList: [String!]! + highlight: Highlight! + overlapHighlightIdList: [String!]! } type MoveFilterError { - errorCodes: [MoveFilterErrorCode!]! + errorCodes: [MoveFilterErrorCode!]! } enum MoveFilterErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input MoveFilterInput { - afterFilterId: ID - filterId: ID! + afterFilterId: ID + filterId: ID! } union MoveFilterResult = MoveFilterError | MoveFilterSuccess type MoveFilterSuccess { - filter: Filter! + filter: Filter! } type MoveLabelError { - errorCodes: [MoveLabelErrorCode!]! + errorCodes: [MoveLabelErrorCode!]! } enum MoveLabelErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input MoveLabelInput { - afterLabelId: ID - labelId: ID! + afterLabelId: ID + labelId: ID! } union MoveLabelResult = MoveLabelError | MoveLabelSuccess type MoveLabelSuccess { - label: Label! + label: Label! } type MoveToFolderError { - errorCodes: [MoveToFolderErrorCode!]! + errorCodes: [MoveToFolderErrorCode!]! } enum MoveToFolderErrorCode { - ALREADY_EXISTS - BAD_REQUEST - UNAUTHORIZED + ALREADY_EXISTS + BAD_REQUEST + UNAUTHORIZED } union MoveToFolderResult = MoveToFolderError | MoveToFolderSuccess type MoveToFolderSuccess { - success: Boolean! + success: Boolean! } type Mutation { - addPopularRead(name: String!): AddPopularReadResult! - bulkAction(action: BulkActionType!, arguments: JSON, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult! - createArticle(input: CreateArticleInput!): CreateArticleResult! - createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! - createGroup(input: CreateGroupInput!): CreateGroupResult! - createHighlight(input: CreateHighlightInput!): CreateHighlightResult! - createLabel(input: CreateLabelInput!): CreateLabelResult! - createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult! - deleteAccount(userID: ID!): DeleteAccountResult! - deleteFilter(id: ID!): DeleteFilterResult! - deleteHighlight(highlightId: ID!): DeleteHighlightResult! - deleteIntegration(id: ID!): DeleteIntegrationResult! - deleteLabel(id: ID!): DeleteLabelResult! - deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! - deleteRule(id: ID!): DeleteRuleResult! - deleteWebhook(id: ID!): DeleteWebhookResult! - emptyTrash: EmptyTrashResult! - fetchContent(id: ID!): FetchContentResult! - generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult! - googleLogin(input: GoogleLoginInput!): LoginResult! - googleSignup(input: GoogleSignupInput!): GoogleSignupResult! - importFromIntegration(integrationId: ID!): ImportFromIntegrationResult! - joinGroup(inviteCode: String!): JoinGroupResult! - leaveGroup(groupId: ID!): LeaveGroupResult! - logOut: LogOutResult! - markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult! - mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! - moveFilter(input: MoveFilterInput!): MoveFilterResult! - moveLabel(input: MoveLabelInput!): MoveLabelResult! - moveToFolder(folder: String!, id: ID!): MoveToFolderResult! - optInFeature(input: OptInFeatureInput!): OptInFeatureResult! - recommend(input: RecommendInput!): RecommendResult! - recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult! - reportItem(input: ReportItemInput!): ReportItemResult! - revokeApiKey(id: ID!): RevokeApiKeyResult! - saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! - saveFile(input: SaveFileInput!): SaveResult! - saveFilter(input: SaveFilterInput!): SaveFilterResult! - savePage(input: SavePageInput!): SaveResult! - saveUrl(input: SaveUrlInput!): SaveResult! - setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult! - setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult! - setFavoriteArticle(id: ID!): SetFavoriteArticleResult! - setIntegration(input: SetIntegrationInput!): SetIntegrationResult! - setLabels(input: SetLabelsInput!): SetLabelsResult! - setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult! - setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult! - setRule(input: SetRuleInput!): SetRuleResult! - setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! - setWebhook(input: SetWebhookInput!): SetWebhookResult! - subscribe(input: SubscribeInput!): SubscribeResult! - unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult! - updateEmail(input: UpdateEmailInput!): UpdateEmailResult! - updateFilter(input: UpdateFilterInput!): UpdateFilterResult! - updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! - updateLabel(input: UpdateLabelInput!): UpdateLabelResult! - updateNewsletterEmail(input: UpdateNewsletterEmailInput!): UpdateNewsletterEmailResult! - updatePage(input: UpdatePageInput!): UpdatePageResult! - updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult! - updateUser(input: UpdateUserInput!): UpdateUserResult! - updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! - uploadFileRequest(input: UploadFileRequestInput!): UploadFileRequestResult! - uploadImportFile(contentType: String!, type: UploadImportFileType!): UploadImportFileResult! + addPopularRead(name: String!): AddPopularReadResult! + bulkAction(action: BulkActionType!, arguments: JSON, async: Boolean, expectedCount: Int, labelIds: [ID!], query: String!): BulkActionResult! + createArticle(input: CreateArticleInput!): CreateArticleResult! + createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! + createGroup(input: CreateGroupInput!): CreateGroupResult! + createHighlight(input: CreateHighlightInput!): CreateHighlightResult! + createLabel(input: CreateLabelInput!): CreateLabelResult! + createNewsletterEmail(input: CreateNewsletterEmailInput): CreateNewsletterEmailResult! + deleteAccount(userID: ID!): DeleteAccountResult! + deleteFilter(id: ID!): DeleteFilterResult! + deleteHighlight(highlightId: ID!): DeleteHighlightResult! + deleteIntegration(id: ID!): DeleteIntegrationResult! + deleteLabel(id: ID!): DeleteLabelResult! + deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! + deleteRule(id: ID!): DeleteRuleResult! + deleteWebhook(id: ID!): DeleteWebhookResult! + emptyTrash: EmptyTrashResult! + fetchContent(id: ID!): FetchContentResult! + generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult! + googleLogin(input: GoogleLoginInput!): LoginResult! + googleSignup(input: GoogleSignupInput!): GoogleSignupResult! + importFromIntegration(integrationId: ID!): ImportFromIntegrationResult! + joinGroup(inviteCode: String!): JoinGroupResult! + leaveGroup(groupId: ID!): LeaveGroupResult! + logOut: LogOutResult! + markEmailAsItem(recentEmailId: ID!): MarkEmailAsItemResult! + mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult! + moveFilter(input: MoveFilterInput!): MoveFilterResult! + moveLabel(input: MoveLabelInput!): MoveLabelResult! + moveToFolder(folder: String!, id: ID!): MoveToFolderResult! + optInFeature(input: OptInFeatureInput!): OptInFeatureResult! + recommend(input: RecommendInput!): RecommendResult! + recommendHighlights(input: RecommendHighlightsInput!): RecommendHighlightsResult! + reportItem(input: ReportItemInput!): ReportItemResult! + revokeApiKey(id: ID!): RevokeApiKeyResult! + saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult! + saveFile(input: SaveFileInput!): SaveResult! + saveFilter(input: SaveFilterInput!): SaveFilterResult! + savePage(input: SavePageInput!): SaveResult! + saveUrl(input: SaveUrlInput!): SaveResult! + setBookmarkArticle(input: SetBookmarkArticleInput!): SetBookmarkArticleResult! + setDeviceToken(input: SetDeviceTokenInput!): SetDeviceTokenResult! + setFavoriteArticle(id: ID!): SetFavoriteArticleResult! + setIntegration(input: SetIntegrationInput!): SetIntegrationResult! + setLabels(input: SetLabelsInput!): SetLabelsResult! + setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult! + setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult! + setRule(input: SetRuleInput!): SetRuleResult! + setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! + setWebhook(input: SetWebhookInput!): SetWebhookResult! + subscribe(input: SubscribeInput!): SubscribeResult! + unsubscribe(name: String!, subscriptionId: ID): UnsubscribeResult! + updateEmail(input: UpdateEmailInput!): UpdateEmailResult! + updateFilter(input: UpdateFilterInput!): UpdateFilterResult! + updateHighlight(input: UpdateHighlightInput!): UpdateHighlightResult! + updateLabel(input: UpdateLabelInput!): UpdateLabelResult! + updateNewsletterEmail(input: UpdateNewsletterEmailInput!): UpdateNewsletterEmailResult! + updatePage(input: UpdatePageInput!): UpdatePageResult! + updateSubscription(input: UpdateSubscriptionInput!): UpdateSubscriptionResult! + updateUser(input: UpdateUserInput!): UpdateUserResult! + updateUserProfile(input: UpdateUserProfileInput!): UpdateUserProfileResult! + uploadFileRequest(input: UploadFileRequestInput!): UploadFileRequestResult! + uploadImportFile(contentType: String!, type: UploadImportFileType!): UploadImportFileResult! } type NewsletterEmail { - address: String! - confirmationCode: String - createdAt: Date! - description: String - folder: String! - id: ID! - name: String - subscriptionCount: Int! + address: String! + confirmationCode: String + createdAt: Date! + description: String + folder: String! + id: ID! + name: String + subscriptionCount: Int! } type NewsletterEmailsError { - errorCodes: [NewsletterEmailsErrorCode!]! + errorCodes: [NewsletterEmailsErrorCode!]! } enum NewsletterEmailsErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union NewsletterEmailsResult = NewsletterEmailsError | NewsletterEmailsSuccess type NewsletterEmailsSuccess { - newsletterEmails: [NewsletterEmail!]! + newsletterEmails: [NewsletterEmail!]! } type OptInFeatureError { - errorCodes: [OptInFeatureErrorCode!]! + errorCodes: [OptInFeatureErrorCode!]! } enum OptInFeatureErrorCode { - BAD_REQUEST - NOT_FOUND + BAD_REQUEST + NOT_FOUND } input OptInFeatureInput { - name: String! + name: String! } union OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess type OptInFeatureSuccess { - feature: Feature! + feature: Feature! } type Page { - author: String - createdAt: Date! - description: String - hash: String! - id: ID! - image: String! - originalHtml: String! - originalUrl: String! - publishedAt: Date - readableHtml: String! - title: String! - type: PageType! - updatedAt: Date - url: String! + author: String + createdAt: Date! + description: String + hash: String! + id: ID! + image: String! + originalHtml: String! + originalUrl: String! + publishedAt: Date + readableHtml: String! + title: String! + type: PageType! + updatedAt: Date + url: String! } type PageInfo { - endCursor: String - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - totalCount: Int + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + totalCount: Int } input PageInfoInput { - author: String - canonicalUrl: String - contentType: String - description: String - previewImage: String - publishedAt: Date - title: String + author: String + canonicalUrl: String + contentType: String + description: String + previewImage: String + publishedAt: Date + title: String } enum PageType { - ARTICLE - BOOK - FILE - HIGHLIGHTS - IMAGE - PROFILE - TWEET - UNKNOWN - VIDEO - WEBSITE + ARTICLE + BOOK + FILE + HIGHLIGHTS + IMAGE + PROFILE + TWEET + UNKNOWN + VIDEO + WEBSITE } input ParseResult { - byline: String - content: String! - dir: String - excerpt: String! - language: String - length: Int! - previewImage: String - publishedDate: Date - siteIcon: String - siteName: String - textContent: String! - title: String! + byline: String + content: String! + dir: String + excerpt: String! + language: String + length: Int! + previewImage: String + publishedDate: Date + siteIcon: String + siteName: String + textContent: String! + title: String! } input PreparedDocumentInput { - document: String! - pageInfo: PageInfoInput! + document: String! + pageInfo: PageInfoInput! } type Profile { - bio: String - id: ID! - pictureUrl: String - private: Boolean! - username: String! + bio: String + id: ID! + pictureUrl: String + private: Boolean! + username: String! } type Query { - apiKeys: ApiKeysResult! - article(format: String, slug: String!, username: String!): ArticleResult! - articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult! - deviceTokens: DeviceTokensResult! - feeds(input: FeedsInput!): FeedsResult! - filters: FiltersResult! - getUserPersonalization: GetUserPersonalizationResult! - groups: GroupsResult! - hello: String - integrations: IntegrationsResult! - labels: LabelsResult! - me: User - newsletterEmails: NewsletterEmailsResult! - recentEmails: RecentEmailsResult! - recentSearches: RecentSearchesResult! - rules(enabled: Boolean): RulesResult! - scanFeeds(input: ScanFeedsInput!): ScanFeedsResult! - search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult! - sendInstallInstructions: SendInstallInstructionsResult! - subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult! - typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult! - updatesSince(after: String, first: Int, folder: String, since: Date!, sort: SortParams): UpdatesSinceResult! - user(userId: ID, username: String): UserResult! - users: UsersResult! - validateUsername(username: String!): Boolean! - webhook(id: ID!): WebhookResult! - webhooks: WebhooksResult! + apiKeys: ApiKeysResult! + article(format: String, slug: String!, username: String!): ArticleResult! + articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult! + deviceTokens: DeviceTokensResult! + feeds(input: FeedsInput!): FeedsResult! + filters: FiltersResult! + getUserPersonalization: GetUserPersonalizationResult! + groups: GroupsResult! + hello: String + integrations: IntegrationsResult! + labels: LabelsResult! + me: User + newsletterEmails: NewsletterEmailsResult! + recentEmails: RecentEmailsResult! + recentSearches: RecentSearchesResult! + rules(enabled: Boolean): RulesResult! + scanFeeds(input: ScanFeedsInput!): ScanFeedsResult! + search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult! + sendInstallInstructions: SendInstallInstructionsResult! + subscriptions(sort: SortParams, type: SubscriptionType): SubscriptionsResult! + typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult! + updatesSince(after: String, first: Int, folder: String, since: Date!, sort: SortParams): UpdatesSinceResult! + user(userId: ID, username: String): UserResult! + users: UsersResult! + validateUsername(username: String!): Boolean! + webhook(id: ID!): WebhookResult! + webhooks: WebhooksResult! } type Reaction { - code: ReactionType! - createdAt: Date! - id: ID! - updatedAt: Date - user: User! + code: ReactionType! + createdAt: Date! + id: ID! + updatedAt: Date + user: User! } enum ReactionType { - CRYING - HEART - HUSHED - LIKE - POUT - SMILE + CRYING + HEART + HUSHED + LIKE + POUT + SMILE } type ReadState { - progressAnchorIndex: Int! - progressPercent: Float! - reading: Boolean - readingTime: Int + progressAnchorIndex: Int! + progressPercent: Float! + reading: Boolean + readingTime: Int } type RecentEmail { - createdAt: Date! - from: String! - html: String - id: ID! - subject: String! - text: String! - to: String! - type: String! + createdAt: Date! + from: String! + html: String + id: ID! + subject: String! + text: String! + to: String! + type: String! } type RecentEmailsError { - errorCodes: [RecentEmailsErrorCode!]! + errorCodes: [RecentEmailsErrorCode!]! } enum RecentEmailsErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union RecentEmailsResult = RecentEmailsError | RecentEmailsSuccess type RecentEmailsSuccess { - recentEmails: [RecentEmail!]! + recentEmails: [RecentEmail!]! } type RecentSearch { - createdAt: Date! - id: ID! - term: String! + createdAt: Date! + id: ID! + term: String! } type RecentSearchesError { - errorCodes: [RecentSearchesErrorCode!]! + errorCodes: [RecentSearchesErrorCode!]! } enum RecentSearchesErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union RecentSearchesResult = RecentSearchesError | RecentSearchesSuccess type RecentSearchesSuccess { - searches: [RecentSearch!]! + searches: [RecentSearch!]! } type RecommendError { - errorCodes: [RecommendErrorCode!]! + errorCodes: [RecommendErrorCode!]! } enum RecommendErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } type RecommendHighlightsError { - errorCodes: [RecommendHighlightsErrorCode!]! + errorCodes: [RecommendHighlightsErrorCode!]! } enum RecommendHighlightsErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input RecommendHighlightsInput { - groupIds: [ID!]! - highlightIds: [ID!]! - note: String - pageId: ID! + groupIds: [ID!]! + highlightIds: [ID!]! + note: String + pageId: ID! } union RecommendHighlightsResult = RecommendHighlightsError | RecommendHighlightsSuccess type RecommendHighlightsSuccess { - success: Boolean! + success: Boolean! } input RecommendInput { - groupIds: [ID!]! - note: String - pageId: ID! - recommendedWithHighlights: Boolean + groupIds: [ID!]! + note: String + pageId: ID! + recommendedWithHighlights: Boolean } union RecommendResult = RecommendError | RecommendSuccess type RecommendSuccess { - success: Boolean! + success: Boolean! } type Recommendation { - id: ID! - name: String! - note: String - recommendedAt: Date! - user: RecommendingUser + id: ID! + name: String! + note: String + recommendedAt: Date! + user: RecommendingUser } type RecommendationGroup { - admins: [User!]! - canPost: Boolean! - canSeeMembers: Boolean! - createdAt: Date! - description: String - id: ID! - inviteUrl: String! - members: [User!]! - name: String! - topics: [String!] - updatedAt: Date + admins: [User!]! + canPost: Boolean! + canSeeMembers: Boolean! + createdAt: Date! + description: String + id: ID! + inviteUrl: String! + members: [User!]! + name: String! + topics: [String!] + updatedAt: Date } type RecommendingUser { - name: String! - profileImageURL: String - userId: String! - username: String! + name: String! + profileImageURL: String + userId: String! + username: String! } type Reminder { - archiveUntil: Boolean! - id: ID! - remindAt: Date! - sendNotification: Boolean! + archiveUntil: Boolean! + id: ID! + remindAt: Date! + sendNotification: Boolean! } type ReminderError { - errorCodes: [ReminderErrorCode!]! + errorCodes: [ReminderErrorCode!]! } enum ReminderErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union ReminderResult = ReminderError | ReminderSuccess type ReminderSuccess { - reminder: Reminder! + reminder: Reminder! } input ReportItemInput { - itemUrl: String! - pageId: ID! - reportComment: String! - reportTypes: [ReportType!]! - sharedBy: ID + itemUrl: String! + pageId: ID! + reportComment: String! + reportTypes: [ReportType!]! + sharedBy: ID } type ReportItemResult { - message: String! + message: String! } enum ReportType { - ABUSIVE - CONTENT_DISPLAY - CONTENT_VIOLATION - SPAM + ABUSIVE + CONTENT_DISPLAY + CONTENT_VIOLATION + SPAM } type RevokeApiKeyError { - errorCodes: [RevokeApiKeyErrorCode!]! + errorCodes: [RevokeApiKeyErrorCode!]! } enum RevokeApiKeyErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union RevokeApiKeyResult = RevokeApiKeyError | RevokeApiKeySuccess type RevokeApiKeySuccess { - apiKey: ApiKey! + apiKey: ApiKey! } type Rule { - actions: [RuleAction!]! - createdAt: Date! - enabled: Boolean! - eventTypes: [RuleEventType!]! - filter: String! - id: ID! - name: String! - updatedAt: Date + actions: [RuleAction!]! + createdAt: Date! + enabled: Boolean! + eventTypes: [RuleEventType!]! + filter: String! + id: ID! + name: String! + updatedAt: Date } type RuleAction { - params: [String!]! - type: RuleActionType! + params: [String!]! + type: RuleActionType! } input RuleActionInput { - params: [String!]! - type: RuleActionType! + params: [String!]! + type: RuleActionType! } enum RuleActionType { - ADD_LABEL - ARCHIVE - MARK_AS_READ - SEND_NOTIFICATION + ADD_LABEL + ARCHIVE + MARK_AS_READ + SEND_NOTIFICATION } enum RuleEventType { - PAGE_CREATED - PAGE_UPDATED + PAGE_CREATED + PAGE_UPDATED } type RulesError { - errorCodes: [RulesErrorCode!]! + errorCodes: [RulesErrorCode!]! } enum RulesErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union RulesResult = RulesError | RulesSuccess type RulesSuccess { - rules: [Rule!]! + rules: [Rule!]! } type SaveArticleReadingProgressError { - errorCodes: [SaveArticleReadingProgressErrorCode!]! + errorCodes: [SaveArticleReadingProgressErrorCode!]! } enum SaveArticleReadingProgressErrorCode { - BAD_DATA - NOT_FOUND - UNAUTHORIZED + BAD_DATA + NOT_FOUND + UNAUTHORIZED } input SaveArticleReadingProgressInput { - force: Boolean - id: ID! - readingProgressAnchorIndex: Int - readingProgressPercent: Float! - readingProgressTopPercent: Float + force: Boolean + id: ID! + readingProgressAnchorIndex: Int + readingProgressPercent: Float! + readingProgressTopPercent: Float } union SaveArticleReadingProgressResult = SaveArticleReadingProgressError | SaveArticleReadingProgressSuccess type SaveArticleReadingProgressSuccess { - updatedArticle: Article! + updatedArticle: Article! } type SaveError { - errorCodes: [SaveErrorCode!]! - message: String + errorCodes: [SaveErrorCode!]! + message: String } enum SaveErrorCode { - EMBEDDED_HIGHLIGHT_FAILED - UNAUTHORIZED - UNKNOWN + EMBEDDED_HIGHLIGHT_FAILED + UNAUTHORIZED + UNKNOWN } input SaveFileInput { - clientRequestId: ID! - folder: String - labels: [CreateLabelInput!] - source: String! - state: ArticleSavingRequestStatus - uploadFileId: ID! - url: String! + clientRequestId: ID! + folder: String + labels: [CreateLabelInput!] + source: String! + state: ArticleSavingRequestStatus + uploadFileId: ID! + url: String! } type SaveFilterError { - errorCodes: [SaveFilterErrorCode!]! + errorCodes: [SaveFilterErrorCode!]! } enum SaveFilterErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input SaveFilterInput { - category: String - description: String - filter: String! - folder: String - name: String! - position: Int + category: String + description: String + filter: String! + folder: String + name: String! + position: Int } union SaveFilterResult = SaveFilterError | SaveFilterSuccess type SaveFilterSuccess { - filter: Filter! + filter: Filter! } input SavePageInput { - clientRequestId: ID! - folder: String - labels: [CreateLabelInput!] - originalContent: String! - parseResult: ParseResult - publishedAt: Date - rssFeedUrl: String - savedAt: Date - source: String! - state: ArticleSavingRequestStatus - title: String - url: String! + clientRequestId: ID! + folder: String + labels: [CreateLabelInput!] + originalContent: String! + parseResult: ParseResult + publishedAt: Date + rssFeedUrl: String + savedAt: Date + source: String! + state: ArticleSavingRequestStatus + title: String + url: String! } union SaveResult = SaveError | SaveSuccess type SaveSuccess { - clientRequestId: ID! - url: String! + clientRequestId: ID! + url: String! } input SaveUrlInput { - clientRequestId: ID! - folder: String - labels: [CreateLabelInput!] - locale: String - publishedAt: Date - savedAt: Date - source: String! - state: ArticleSavingRequestStatus - timezone: String - url: String! + clientRequestId: ID! + folder: String + labels: [CreateLabelInput!] + locale: String + publishedAt: Date + savedAt: Date + source: String! + state: ArticleSavingRequestStatus + timezone: String + url: String! } type ScanFeedsError { - errorCodes: [ScanFeedsErrorCode!]! + errorCodes: [ScanFeedsErrorCode!]! } enum ScanFeedsErrorCode { - BAD_REQUEST + BAD_REQUEST } input ScanFeedsInput { - opml: String - url: String + opml: String + url: String } union ScanFeedsResult = ScanFeedsError | ScanFeedsSuccess type ScanFeedsSuccess { - feeds: [Feed!]! + feeds: [Feed!]! } type SearchError { - errorCodes: [SearchErrorCode!]! + errorCodes: [SearchErrorCode!]! } enum SearchErrorCode { - QUERY_TOO_LONG - UNAUTHORIZED + QUERY_TOO_LONG + UNAUTHORIZED } type SearchItem { - annotation: String - archivedAt: Date - author: String - color: String - content: String - contentReader: ContentReader! - createdAt: Date! - description: String - folder: String! - highlights: [Highlight!] - id: ID! - image: String - isArchived: Boolean! - labels: [Label!] - language: String - links: JSON - originalArticleUrl: String - ownedByViewer: Boolean - pageId: ID - pageType: PageType! - previewContent: String - previewContentType: String - publishedAt: Date - quote: String - readAt: Date - readingProgressAnchorIndex: Int! - readingProgressPercent: Float! - readingProgressTopPercent: Float - recommendations: [Recommendation!] - savedAt: Date! - shortId: String - siteIcon: String - siteName: String - slug: String! - state: ArticleSavingRequestStatus - subscription: String - title: String! - unsubHttpUrl: String - unsubMailTo: String - updatedAt: Date - uploadFileId: ID - url: String! - wordsCount: Int + annotation: String + archivedAt: Date + author: String + color: String + content: String + contentReader: ContentReader! + createdAt: Date! + description: String + folder: String! + highlights: [Highlight!] + id: ID! + image: String + isArchived: Boolean! + labels: [Label!] + language: String + links: JSON + originalArticleUrl: String + ownedByViewer: Boolean + pageId: ID + pageType: PageType! + previewContent: String + previewContentType: String + publishedAt: Date + quote: String + readAt: Date + readingProgressAnchorIndex: Int! + readingProgressPercent: Float! + readingProgressTopPercent: Float + recommendations: [Recommendation!] + savedAt: Date! + shortId: String + siteIcon: String + siteName: String + slug: String! + state: ArticleSavingRequestStatus + subscription: String + title: String! + unsubHttpUrl: String + unsubMailTo: String + updatedAt: Date + uploadFileId: ID + url: String! + wordsCount: Int } type SearchItemEdge { - cursor: String! - node: SearchItem! + cursor: String! + node: SearchItem! } union SearchResult = SearchError | SearchSuccess type SearchSuccess { - edges: [SearchItemEdge!]! - pageInfo: PageInfo! + edges: [SearchItemEdge!]! + pageInfo: PageInfo! } type SendInstallInstructionsError { - errorCodes: [SendInstallInstructionsErrorCode!]! + errorCodes: [SendInstallInstructionsErrorCode!]! } enum SendInstallInstructionsErrorCode { - BAD_REQUEST - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } union SendInstallInstructionsResult = SendInstallInstructionsError | SendInstallInstructionsSuccess type SendInstallInstructionsSuccess { - sent: Boolean! + sent: Boolean! } type SetBookmarkArticleError { - errorCodes: [SetBookmarkArticleErrorCode!]! + errorCodes: [SetBookmarkArticleErrorCode!]! } enum SetBookmarkArticleErrorCode { - BOOKMARK_EXISTS - NOT_FOUND + BOOKMARK_EXISTS + NOT_FOUND } input SetBookmarkArticleInput { - articleID: ID! - bookmark: Boolean! + articleID: ID! + bookmark: Boolean! } union SetBookmarkArticleResult = SetBookmarkArticleError | SetBookmarkArticleSuccess type SetBookmarkArticleSuccess { - bookmarkedArticle: Article! + bookmarkedArticle: Article! } type SetDeviceTokenError { - errorCodes: [SetDeviceTokenErrorCode!]! + errorCodes: [SetDeviceTokenErrorCode!]! } enum SetDeviceTokenErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input SetDeviceTokenInput { - id: ID - token: String + id: ID + token: String } union SetDeviceTokenResult = SetDeviceTokenError | SetDeviceTokenSuccess type SetDeviceTokenSuccess { - deviceToken: DeviceToken! + deviceToken: DeviceToken! } type SetFavoriteArticleError { - errorCodes: [SetFavoriteArticleErrorCode!]! + errorCodes: [SetFavoriteArticleErrorCode!]! } enum SetFavoriteArticleErrorCode { - ALREADY_EXISTS - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + ALREADY_EXISTS + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } union SetFavoriteArticleResult = SetFavoriteArticleError | SetFavoriteArticleSuccess type SetFavoriteArticleSuccess { - success: Boolean! + success: Boolean! } type SetFollowError { - errorCodes: [SetFollowErrorCode!]! + errorCodes: [SetFollowErrorCode!]! } enum SetFollowErrorCode { - NOT_FOUND - UNAUTHORIZED + NOT_FOUND + UNAUTHORIZED } input SetFollowInput { - follow: Boolean! - userId: ID! + follow: Boolean! + userId: ID! } union SetFollowResult = SetFollowError | SetFollowSuccess type SetFollowSuccess { - updatedUser: User! + updatedUser: User! } type SetIntegrationError { - errorCodes: [SetIntegrationErrorCode!]! + errorCodes: [SetIntegrationErrorCode!]! } enum SetIntegrationErrorCode { - ALREADY_EXISTS - BAD_REQUEST - INVALID_TOKEN - NOT_FOUND - UNAUTHORIZED + ALREADY_EXISTS + BAD_REQUEST + INVALID_TOKEN + NOT_FOUND + UNAUTHORIZED } input SetIntegrationInput { - enabled: Boolean! - id: ID - importItemState: ImportItemState - name: String! - syncedAt: Date - taskName: String - token: String! - type: IntegrationType + enabled: Boolean! + id: ID + importItemState: ImportItemState + name: String! + syncedAt: Date + taskName: String + token: String! + type: IntegrationType } union SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess type SetIntegrationSuccess { - integration: Integration! + integration: Integration! } type SetLabelsError { - errorCodes: [SetLabelsErrorCode!]! + errorCodes: [SetLabelsErrorCode!]! } enum SetLabelsErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input SetLabelsForHighlightInput { - highlightId: ID! - labelIds: [ID!] - labels: [CreateLabelInput!] + highlightId: ID! + labelIds: [ID!] + labels: [CreateLabelInput!] } input SetLabelsInput { - labelIds: [ID!] - labels: [CreateLabelInput!] - pageId: ID! - source: String + labelIds: [ID!] + labels: [CreateLabelInput!] + pageId: ID! + source: String } union SetLabelsResult = SetLabelsError | SetLabelsSuccess type SetLabelsSuccess { - labels: [Label!]! + labels: [Label!]! } type SetRuleError { - errorCodes: [SetRuleErrorCode!]! + errorCodes: [SetRuleErrorCode!]! } enum SetRuleErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input SetRuleInput { - actions: [RuleActionInput!]! - description: String - enabled: Boolean! - eventTypes: [RuleEventType!]! - filter: String! - id: ID - name: String! + actions: [RuleActionInput!]! + description: String + enabled: Boolean! + eventTypes: [RuleEventType!]! + filter: String! + id: ID + name: String! } union SetRuleResult = SetRuleError | SetRuleSuccess type SetRuleSuccess { - rule: Rule! + rule: Rule! } type SetShareArticleError { - errorCodes: [SetShareArticleErrorCode!]! + errorCodes: [SetShareArticleErrorCode!]! } enum SetShareArticleErrorCode { - NOT_FOUND - UNAUTHORIZED + NOT_FOUND + UNAUTHORIZED } input SetShareArticleInput { - articleID: ID! - share: Boolean! - sharedComment: String - sharedWithHighlights: Boolean + articleID: ID! + share: Boolean! + sharedComment: String + sharedWithHighlights: Boolean } union SetShareArticleResult = SetShareArticleError | SetShareArticleSuccess type SetShareArticleSuccess { - updatedArticle: Article! - updatedFeedArticle: FeedArticle - updatedFeedArticleId: String + updatedArticle: Article! + updatedFeedArticle: FeedArticle + updatedFeedArticleId: String } type SetShareHighlightError { - errorCodes: [SetShareHighlightErrorCode!]! + errorCodes: [SetShareHighlightErrorCode!]! } enum SetShareHighlightErrorCode { - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input SetShareHighlightInput { - id: ID! - share: Boolean! + id: ID! + share: Boolean! } union SetShareHighlightResult = SetShareHighlightError | SetShareHighlightSuccess type SetShareHighlightSuccess { - highlight: Highlight! + highlight: Highlight! } type SetUserPersonalizationError { - errorCodes: [SetUserPersonalizationErrorCode!]! + errorCodes: [SetUserPersonalizationErrorCode!]! } enum SetUserPersonalizationErrorCode { - NOT_FOUND - UNAUTHORIZED + NOT_FOUND + UNAUTHORIZED } input SetUserPersonalizationInput { - fields: JSON - fontFamily: String - fontSize: Int - libraryLayoutType: String - librarySortOrder: SortOrder - margin: Int - speechRate: String - speechSecondaryVoice: String - speechVoice: String - speechVolume: String - theme: String + fields: JSON + fontFamily: String + fontSize: Int + libraryLayoutType: String + librarySortOrder: SortOrder + margin: Int + speechRate: String + speechSecondaryVoice: String + speechVoice: String + speechVolume: String + theme: String } union SetUserPersonalizationResult = SetUserPersonalizationError | SetUserPersonalizationSuccess type SetUserPersonalizationSuccess { - updatedUserPersonalization: UserPersonalization! + updatedUserPersonalization: UserPersonalization! } type SetWebhookError { - errorCodes: [SetWebhookErrorCode!]! + errorCodes: [SetWebhookErrorCode!]! } enum SetWebhookErrorCode { - ALREADY_EXISTS - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + ALREADY_EXISTS + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input SetWebhookInput { - contentType: String - enabled: Boolean - eventTypes: [WebhookEvent!]! - id: ID - method: String - url: String! + contentType: String + enabled: Boolean + eventTypes: [WebhookEvent!]! + id: ID + method: String + url: String! } union SetWebhookResult = SetWebhookError | SetWebhookSuccess type SetWebhookSuccess { - webhook: Webhook! + webhook: Webhook! } type ShareStats { - readDuration: Int! - saveCount: Int! - viewCount: Int! + readDuration: Int! + saveCount: Int! + viewCount: Int! } type SharedArticleError { - errorCodes: [SharedArticleErrorCode!]! + errorCodes: [SharedArticleErrorCode!]! } enum SharedArticleErrorCode { - NOT_FOUND + NOT_FOUND } union SharedArticleResult = SharedArticleError | SharedArticleSuccess type SharedArticleSuccess { - article: Article! + article: Article! } enum SignupErrorCode { - ACCESS_DENIED - EXPIRED_TOKEN - GOOGLE_AUTH_ERROR - INVALID_EMAIL - INVALID_PASSWORD - INVALID_USERNAME - UNKNOWN - USER_EXISTS + ACCESS_DENIED + EXPIRED_TOKEN + GOOGLE_AUTH_ERROR + INVALID_EMAIL + INVALID_PASSWORD + INVALID_USERNAME + UNKNOWN + USER_EXISTS } enum SortBy { - PUBLISHED_AT - SAVED_AT - SCORE - UPDATED_TIME + PUBLISHED_AT + SAVED_AT + SCORE + UPDATED_TIME } enum SortOrder { - ASCENDING - DESCENDING + ASCENDING + DESCENDING } input SortParams { - by: SortBy! - order: SortOrder + by: SortBy! + order: SortOrder } type SubscribeError { - errorCodes: [SubscribeErrorCode!]! + errorCodes: [SubscribeErrorCode!]! } enum SubscribeErrorCode { - ALREADY_SUBSCRIBED - BAD_REQUEST - EXCEEDED_MAX_SUBSCRIPTIONS - NOT_FOUND - UNAUTHORIZED + ALREADY_SUBSCRIBED + BAD_REQUEST + EXCEEDED_MAX_SUBSCRIPTIONS + NOT_FOUND + UNAUTHORIZED } input SubscribeInput { - autoAddToLibrary: Boolean - fetchContent: Boolean - folder: String - isPrivate: Boolean - subscriptionType: SubscriptionType - url: String! + autoAddToLibrary: Boolean + fetchContent: Boolean + folder: String + isPrivate: Boolean + subscriptionType: SubscriptionType + url: String! } union SubscribeResult = SubscribeError | SubscribeSuccess type SubscribeSuccess { - subscriptions: [Subscription!]! + subscriptions: [Subscription!]! } type Subscription { - autoAddToLibrary: Boolean - count: Int! - createdAt: Date! - description: String - fetchContent: Boolean! - folder: String! - icon: String - id: ID! - isPrivate: Boolean - lastFetchedAt: Date - name: String! - newsletterEmail: String - status: SubscriptionStatus! - type: SubscriptionType! - unsubscribeHttpUrl: String - unsubscribeMailTo: String - updatedAt: Date - url: String + autoAddToLibrary: Boolean + count: Int! + createdAt: Date! + description: String + fetchContent: Boolean! + folder: String! + icon: String + id: ID! + isPrivate: Boolean + lastFetchedAt: Date + name: String! + newsletterEmail: String + status: SubscriptionStatus! + type: SubscriptionType! + unsubscribeHttpUrl: String + unsubscribeMailTo: String + updatedAt: Date + url: String } enum SubscriptionStatus { - ACTIVE - DELETED - UNSUBSCRIBED + ACTIVE + DELETED + UNSUBSCRIBED } enum SubscriptionType { - NEWSLETTER - RSS + NEWSLETTER + RSS } type SubscriptionsError { - errorCodes: [SubscriptionsErrorCode!]! + errorCodes: [SubscriptionsErrorCode!]! } enum SubscriptionsErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union SubscriptionsResult = SubscriptionsError | SubscriptionsSuccess type SubscriptionsSuccess { - subscriptions: [Subscription!]! + subscriptions: [Subscription!]! } type SyncUpdatedItemEdge { - cursor: String! - itemID: ID! - node: SearchItem - updateReason: UpdateReason! + cursor: String! + itemID: ID! + node: SearchItem + updateReason: UpdateReason! } type TypeaheadSearchError { - errorCodes: [TypeaheadSearchErrorCode!]! + errorCodes: [TypeaheadSearchErrorCode!]! } enum TypeaheadSearchErrorCode { - UNAUTHORIZED + UNAUTHORIZED } type TypeaheadSearchItem { - contentReader: ContentReader! - id: ID! - siteName: String - slug: String! - title: String! + contentReader: ContentReader! + id: ID! + siteName: String + slug: String! + title: String! } union TypeaheadSearchResult = TypeaheadSearchError | TypeaheadSearchSuccess type TypeaheadSearchSuccess { - items: [TypeaheadSearchItem!]! + items: [TypeaheadSearchItem!]! } type UnsubscribeError { - errorCodes: [UnsubscribeErrorCode!]! + errorCodes: [UnsubscribeErrorCode!]! } enum UnsubscribeErrorCode { - ALREADY_UNSUBSCRIBED - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED - UNSUBSCRIBE_METHOD_NOT_FOUND + ALREADY_UNSUBSCRIBED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED + UNSUBSCRIBE_METHOD_NOT_FOUND } union UnsubscribeResult = UnsubscribeError | UnsubscribeSuccess type UnsubscribeSuccess { - subscription: Subscription! + subscription: Subscription! } type UpdateEmailError { - errorCodes: [UpdateEmailErrorCode!]! + errorCodes: [UpdateEmailErrorCode!]! } enum UpdateEmailErrorCode { - BAD_REQUEST - EMAIL_ALREADY_EXISTS - UNAUTHORIZED + BAD_REQUEST + EMAIL_ALREADY_EXISTS + UNAUTHORIZED } input UpdateEmailInput { - email: String! + email: String! } union UpdateEmailResult = UpdateEmailError | UpdateEmailSuccess type UpdateEmailSuccess { - email: String! - verificationEmailSent: Boolean + email: String! + verificationEmailSent: Boolean } type UpdateFilterError { - errorCodes: [UpdateFilterErrorCode!]! + errorCodes: [UpdateFilterErrorCode!]! } enum UpdateFilterErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input UpdateFilterInput { - category: String - description: String - filter: String - folder: String - id: String! - name: String - position: Int - visible: Boolean + category: String + description: String + filter: String + folder: String + id: String! + name: String + position: Int + visible: Boolean } union UpdateFilterResult = UpdateFilterError | UpdateFilterSuccess type UpdateFilterSuccess { - filter: Filter! + filter: Filter! } type UpdateHighlightError { - errorCodes: [UpdateHighlightErrorCode!]! + errorCodes: [UpdateHighlightErrorCode!]! } enum UpdateHighlightErrorCode { - BAD_DATA - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + BAD_DATA + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input UpdateHighlightInput { - annotation: String - color: String - highlightId: ID! - html: String - quote: String - sharedAt: Date + annotation: String + color: String + highlightId: ID! + html: String + quote: String + sharedAt: Date } type UpdateHighlightReplyError { - errorCodes: [UpdateHighlightReplyErrorCode!]! + errorCodes: [UpdateHighlightReplyErrorCode!]! } enum UpdateHighlightReplyErrorCode { - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input UpdateHighlightReplyInput { - highlightReplyId: ID! - text: String! + highlightReplyId: ID! + text: String! } union UpdateHighlightReplyResult = UpdateHighlightReplyError | UpdateHighlightReplySuccess type UpdateHighlightReplySuccess { - highlightReply: HighlightReply! + highlightReply: HighlightReply! } union UpdateHighlightResult = UpdateHighlightError | UpdateHighlightSuccess type UpdateHighlightSuccess { - highlight: Highlight! + highlight: Highlight! } type UpdateLabelError { - errorCodes: [UpdateLabelErrorCode!]! + errorCodes: [UpdateLabelErrorCode!]! } enum UpdateLabelErrorCode { - BAD_REQUEST - FORBIDDEN - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + FORBIDDEN + NOT_FOUND + UNAUTHORIZED } input UpdateLabelInput { - color: String! - description: String - labelId: ID! - name: String! + color: String! + description: String + labelId: ID! + name: String! } union UpdateLabelResult = UpdateLabelError | UpdateLabelSuccess type UpdateLabelSuccess { - label: Label! + label: Label! } type UpdateLinkShareInfoError { - errorCodes: [UpdateLinkShareInfoErrorCode!]! + errorCodes: [UpdateLinkShareInfoErrorCode!]! } enum UpdateLinkShareInfoErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } input UpdateLinkShareInfoInput { - description: String! - linkId: ID! - title: String! + description: String! + linkId: ID! + title: String! } union UpdateLinkShareInfoResult = UpdateLinkShareInfoError | UpdateLinkShareInfoSuccess type UpdateLinkShareInfoSuccess { - message: String! + message: String! } type UpdateNewsletterEmailError { - errorCodes: [UpdateNewsletterEmailErrorCode!]! + errorCodes: [UpdateNewsletterEmailErrorCode!]! } enum UpdateNewsletterEmailErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } input UpdateNewsletterEmailInput { - description: String - folder: String - id: ID! - name: String + description: String + folder: String + id: ID! + name: String } union UpdateNewsletterEmailResult = UpdateNewsletterEmailError | UpdateNewsletterEmailSuccess type UpdateNewsletterEmailSuccess { - newsletterEmail: NewsletterEmail! + newsletterEmail: NewsletterEmail! } type UpdatePageError { - errorCodes: [UpdatePageErrorCode!]! + errorCodes: [UpdatePageErrorCode!]! } enum UpdatePageErrorCode { - BAD_REQUEST - FORBIDDEN - NOT_FOUND - UNAUTHORIZED - UPDATE_FAILED + BAD_REQUEST + FORBIDDEN + NOT_FOUND + UNAUTHORIZED + UPDATE_FAILED } input UpdatePageInput { - byline: String - description: String - pageId: ID! - previewImage: String - publishedAt: Date - savedAt: Date - state: ArticleSavingRequestStatus - title: String + byline: String + description: String + pageId: ID! + previewImage: String + publishedAt: Date + savedAt: Date + state: ArticleSavingRequestStatus + title: String } union UpdatePageResult = UpdatePageError | UpdatePageSuccess type UpdatePageSuccess { - updatedPage: Article! + updatedPage: Article! } enum UpdateReason { - CREATED - DELETED - UPDATED + CREATED + DELETED + UPDATED } type UpdateReminderError { - errorCodes: [UpdateReminderErrorCode!]! + errorCodes: [UpdateReminderErrorCode!]! } enum UpdateReminderErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input UpdateReminderInput { - archiveUntil: Boolean! - id: ID! - remindAt: Date! - sendNotification: Boolean! + archiveUntil: Boolean! + id: ID! + remindAt: Date! + sendNotification: Boolean! } union UpdateReminderResult = UpdateReminderError | UpdateReminderSuccess type UpdateReminderSuccess { - reminder: Reminder! + reminder: Reminder! } type UpdateSharedCommentError { - errorCodes: [UpdateSharedCommentErrorCode!]! + errorCodes: [UpdateSharedCommentErrorCode!]! } enum UpdateSharedCommentErrorCode { - NOT_FOUND - UNAUTHORIZED + NOT_FOUND + UNAUTHORIZED } input UpdateSharedCommentInput { - articleID: ID! - sharedComment: String! + articleID: ID! + sharedComment: String! } union UpdateSharedCommentResult = UpdateSharedCommentError | UpdateSharedCommentSuccess type UpdateSharedCommentSuccess { - articleID: ID! - sharedComment: String! + articleID: ID! + sharedComment: String! } type UpdateSubscriptionError { - errorCodes: [UpdateSubscriptionErrorCode!]! + errorCodes: [UpdateSubscriptionErrorCode!]! } enum UpdateSubscriptionErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } input UpdateSubscriptionInput { - autoAddToLibrary: Boolean - description: String - fetchContent: Boolean - folder: String - id: ID! - isPrivate: Boolean - lastFetchedAt: Date - lastFetchedChecksum: String - name: String - scheduledAt: Date - status: SubscriptionStatus + autoAddToLibrary: Boolean + description: String + fetchContent: Boolean + folder: String + id: ID! + isPrivate: Boolean + lastFetchedAt: Date + lastFetchedChecksum: String + name: String + scheduledAt: Date + status: SubscriptionStatus } union UpdateSubscriptionResult = UpdateSubscriptionError | UpdateSubscriptionSuccess type UpdateSubscriptionSuccess { - subscription: Subscription! + subscription: Subscription! } type UpdateUserError { - errorCodes: [UpdateUserErrorCode!]! + errorCodes: [UpdateUserErrorCode!]! } enum UpdateUserErrorCode { - BIO_TOO_LONG - EMPTY_NAME - UNAUTHORIZED - USER_NOT_FOUND + BIO_TOO_LONG + EMPTY_NAME + UNAUTHORIZED + USER_NOT_FOUND } input UpdateUserInput { - bio: String - name: String! + bio: String + name: String! } type UpdateUserProfileError { - errorCodes: [UpdateUserProfileErrorCode!]! + errorCodes: [UpdateUserProfileErrorCode!]! } enum UpdateUserProfileErrorCode { - BAD_DATA - BAD_USERNAME - FORBIDDEN - UNAUTHORIZED - USERNAME_EXISTS + BAD_DATA + BAD_USERNAME + FORBIDDEN + UNAUTHORIZED + USERNAME_EXISTS } input UpdateUserProfileInput { - bio: String - pictureUrl: String - userId: ID! - username: String + bio: String + pictureUrl: String + userId: ID! + username: String } union UpdateUserProfileResult = UpdateUserProfileError | UpdateUserProfileSuccess type UpdateUserProfileSuccess { - user: User! + user: User! } union UpdateUserResult = UpdateUserError | UpdateUserSuccess type UpdateUserSuccess { - user: User! + user: User! } type UpdatesSinceError { - errorCodes: [UpdatesSinceErrorCode!]! + errorCodes: [UpdatesSinceErrorCode!]! } enum UpdatesSinceErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union UpdatesSinceResult = UpdatesSinceError | UpdatesSinceSuccess type UpdatesSinceSuccess { - edges: [SyncUpdatedItemEdge!]! - pageInfo: PageInfo! + edges: [SyncUpdatedItemEdge!]! + pageInfo: PageInfo! } type UploadFileRequestError { - errorCodes: [UploadFileRequestErrorCode!]! + errorCodes: [UploadFileRequestErrorCode!]! } enum UploadFileRequestErrorCode { - BAD_INPUT - FAILED_CREATE - UNAUTHORIZED + BAD_INPUT + FAILED_CREATE + UNAUTHORIZED } input UploadFileRequestInput { - clientRequestId: String - contentType: String! - createPageEntry: Boolean - url: String! + clientRequestId: String + contentType: String! + createPageEntry: Boolean + url: String! } union UploadFileRequestResult = UploadFileRequestError | UploadFileRequestSuccess type UploadFileRequestSuccess { - createdPageId: String - id: ID! - uploadFileId: ID - uploadSignedUrl: String + createdPageId: String + id: ID! + uploadFileId: ID + uploadSignedUrl: String } enum UploadFileStatus { - COMPLETED - INITIALIZED + COMPLETED + INITIALIZED } type UploadImportFileError { - errorCodes: [UploadImportFileErrorCode!]! + errorCodes: [UploadImportFileErrorCode!]! } enum UploadImportFileErrorCode { - BAD_REQUEST - UNAUTHORIZED - UPLOAD_DAILY_LIMIT_EXCEEDED + BAD_REQUEST + UNAUTHORIZED + UPLOAD_DAILY_LIMIT_EXCEEDED } union UploadImportFileResult = UploadImportFileError | UploadImportFileSuccess type UploadImportFileSuccess { - uploadSignedUrl: String + uploadSignedUrl: String } enum UploadImportFileType { - MATTER - POCKET - URL_LIST + MATTER + POCKET + URL_LIST } type User { - email: String - followersCount: Int - friendsCount: Int - id: ID! - intercomHash: String - isFriend: Boolean @deprecated(reason: "isFriend has been replaced with viewerIsFollowing") - isFullUser: Boolean - name: String! - picture: String - profile: Profile! - sharedArticles: [FeedArticle!]! - sharedArticlesCount: Int - sharedHighlightsCount: Int - sharedNotesCount: Int - source: String - viewerIsFollowing: Boolean + email: String + followersCount: Int + friendsCount: Int + id: ID! + intercomHash: String + isFriend: Boolean @deprecated(reason: "isFriend has been replaced with viewerIsFollowing") + isFullUser: Boolean + name: String! + picture: String + profile: Profile! + sharedArticles: [FeedArticle!]! + sharedArticlesCount: Int + sharedHighlightsCount: Int + sharedNotesCount: Int + source: String + viewerIsFollowing: Boolean } type UserError { - errorCodes: [UserErrorCode!]! + errorCodes: [UserErrorCode!]! } enum UserErrorCode { - BAD_REQUEST - UNAUTHORIZED - USER_NOT_FOUND + BAD_REQUEST + UNAUTHORIZED + USER_NOT_FOUND } type UserPersonalization { - fields: JSON - fontFamily: String - fontSize: Int - id: ID - libraryLayoutType: String - librarySortOrder: SortOrder - margin: Int - speechRate: String - speechSecondaryVoice: String - speechVoice: String - speechVolume: String - theme: String + fields: JSON + fontFamily: String + fontSize: Int + id: ID + libraryLayoutType: String + librarySortOrder: SortOrder + margin: Int + speechRate: String + speechSecondaryVoice: String + speechVoice: String + speechVolume: String + theme: String } union UserResult = UserError | UserSuccess type UserSuccess { - user: User! + user: User! } type UsersError { - errorCodes: [UsersErrorCode!]! + errorCodes: [UsersErrorCode!]! } enum UsersErrorCode { - UNAUTHORIZED + UNAUTHORIZED } union UsersResult = UsersError | UsersSuccess type UsersSuccess { - users: [User!]! + users: [User!]! } type Webhook { - contentType: String! - createdAt: Date! - enabled: Boolean! - eventTypes: [WebhookEvent!]! - id: ID! - method: String! - updatedAt: Date - url: String! + contentType: String! + createdAt: Date! + enabled: Boolean! + eventTypes: [WebhookEvent!]! + id: ID! + method: String! + updatedAt: Date + url: String! } type WebhookError { - errorCodes: [WebhookErrorCode!]! + errorCodes: [WebhookErrorCode!]! } enum WebhookErrorCode { - BAD_REQUEST - NOT_FOUND - UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED } enum WebhookEvent { - HIGHLIGHT_CREATED - HIGHLIGHT_DELETED - HIGHLIGHT_UPDATED - LABEL_CREATED - LABEL_DELETED - LABEL_UPDATED - PAGE_CREATED - PAGE_DELETED - PAGE_UPDATED + HIGHLIGHT_CREATED + HIGHLIGHT_DELETED + HIGHLIGHT_UPDATED + LABEL_CREATED + LABEL_DELETED + LABEL_UPDATED + PAGE_CREATED + PAGE_DELETED + PAGE_UPDATED } union WebhookResult = WebhookError | WebhookSuccess type WebhookSuccess { - webhook: Webhook! + webhook: Webhook! } type WebhooksError { - errorCodes: [WebhooksErrorCode!]! + errorCodes: [WebhooksErrorCode!]! } enum WebhooksErrorCode { - BAD_REQUEST - UNAUTHORIZED + BAD_REQUEST + UNAUTHORIZED } union WebhooksResult = WebhooksError | WebhooksSuccess type WebhooksSuccess { - webhooks: [Webhook!]! + webhooks: [Webhook!]! } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt index 4588dba40..108df99ea 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt @@ -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() } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt index ac58089a3..639a9a903 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/LibrarySync.kt @@ -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, - val cursor: String? + val hasError: Boolean, + val hasMoreItems: Boolean, + val count: Int, + val savedItemSlugs: List, + 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, - val cursor: String? + val hasError: Boolean, + val hasMoreItems: Boolean, + val count: Int, + val savedItems: List, + 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() + ) + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SavedItemLabelSync.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SavedItemLabelSync.kt deleted file mode 100644 index 8ad37f25e..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SavedItemLabelSync.kt +++ /dev/null @@ -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) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SyncOfflineChanges.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SyncOfflineChanges.kt index 7247d8702..da66f66fc 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SyncOfflineChanges.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/SyncOfflineChanges.kt @@ -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 - } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/model/LibraryQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/model/LibraryQuery.kt index fe33fa46b..e6f56f4b2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/model/LibraryQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/model/LibraryQuery.kt @@ -1,6 +1,7 @@ package app.omnivore.omnivore.core.data.model data class LibraryQuery( + val folders: List, val allowedArchiveStates: List, val sortKey: String, val requiredLabels: List, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt index fe290b209..67091e73c 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/LibraryRepository.kt @@ -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> + fun getSavedItemsLabels(): Flow> + + suspend fun getLabels(): List + + suspend fun fetchSavedItemContent(slug: String) + + suspend fun insertAllLabels(labels: List) + + suspend fun setSavedItemLabels(itemId: String, labels: List): 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 } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt index f7f748a19..989d3fe89 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/data/repository/impl/LibraryRepositoryImpl.kt @@ -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> = 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> = savedItemLabelDao.getSavedItemLabels() + + override suspend fun getLabels(): List = networker.savedItemLabels() + + override suspend fun insertAllLabels(labels: List) { + 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 + ): 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 }) + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt index 7739afffb..93ccfbbd1 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/OmnivoreDatabase.kt @@ -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() { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/HighlightChangesDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/HighlightChangesDao.kt new file mode 100644 index 000000000..b1e08984a --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/HighlightChangesDao.kt @@ -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 + + @Query("DELETE FROM highlightChange WHERE highlightId = :highlightId") + fun deleteById(highlightId: String) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(items: List) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/HighlightDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/HighlightDao.kt new file mode 100644 index 000000000..aa8fc2bb6 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/HighlightDao.kt @@ -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 + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(items: List) + + @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) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemAndSavedItemLabelCrossRefDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemAndSavedItemLabelCrossRefDao.kt new file mode 100644 index 000000000..0e34b4a4c --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemAndSavedItemLabelCrossRefDao.kt @@ -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) + + @Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId") + suspend fun deleteRefsBySavedItemId(savedItemId: String) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt index 3e26960e0..650cedde4 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemDao.kt @@ -23,10 +23,10 @@ interface SavedItemDao { fun getUnSynced(): List @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) @@ -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, allowedArchiveStates: List, sortKey: String, hasRequiredLabels: Int, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemLabelDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemLabelDao.kt new file mode 100644 index 000000000..af75d6c0c --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemLabelDao.kt @@ -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) + + @Transaction + @Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC") + fun getSavedItemLabels(): Flow> + + @Transaction + @Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC") + fun getSavedItemLabelsLiveData(): LiveData> + + @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): List +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemWithLabelsAndHighlightsDao.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemWithLabelsAndHighlightsDao.kt new file mode 100644 index 000000000..45f9f45c0 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/dao/SavedItemWithLabelsAndHighlightsDao.kt @@ -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) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertLabelCrossRefs(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertLabels(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertHighlights(items: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertHighlightCrossRefs(items: List) + + @Transaction + open suspend fun insertAll(savedItems: List) { + insertSavedItems(savedItems.map { it.savedItem }) + + val labels: MutableList = mutableListOf() + val highlights: MutableList = mutableListOf() + + val labelCrossRefs: MutableList = mutableListOf() + val highlightCrossRefs: MutableList = 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) + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/Highlight.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/Highlight.kt index 50c078ab0..0e06cb9be 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/Highlight.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/Highlight.kt @@ -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 - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertAll(items: List) - - @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) -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/HighlightChange.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/HighlightChange.kt index 8a09cb0cb..2d14618be 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/HighlightChange.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/HighlightChange.kt @@ -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 - - @Query("DELETE FROM highlightChange WHERE highlightId = :highlightId") - fun deleteById(highlightId: String) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertAll(items: List) -} - diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItem.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItem.kt index fe4abc810..fd2796f0a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItem.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItem.kt @@ -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) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract fun insertLabelCrossRefs(items: List) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract fun insertLabels(items: List) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract fun insertHighlights(items: List) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - abstract fun insertHighlightCrossRefs(items: List) - - @Transaction - open fun insertAll(savedItems: List) { - insertSavedItems(savedItems.map { it.savedItem }) - - val labels: MutableList = mutableListOf() - val highlights: MutableList = mutableListOf() - - val labelCrossRefs: MutableList = mutableListOf() - val highlightCrossRefs: MutableList = 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, " + diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItemLabel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItemLabel.kt index a014cc3e2..854fe8414 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItemLabel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/database/entities/SavedItemLabel.kt @@ -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) - - @Transaction - @Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC") - fun getSavedItemLabelsLiveData(): LiveData> - - @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): List -} - @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) - - @Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId") - fun deleteRefsBySavedItemId(savedItemId: String) -} - -// has many highlights -// has many savedItems diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreKeys.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreKeys.kt new file mode 100644 index 000000000..93c01559f --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DataStoreKeys.kt @@ -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" diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DatastoreRepository.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DatastoreRepository.kt index 1d9a6fa86..c93c8838e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DatastoreRepository.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/datastore/DatastoreRepository.kt @@ -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 - val themeKeyFlow: Flow + val hasAuthTokenFlow: Flow + val themeKeyFlow: Flow - 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 + 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 by preferencesDataStore( - name = Constants.dataStoreName - ) + private val Context.dataStore: DataStore 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 = 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 = context - .dataStore.data.map { preferences -> - val key = stringPreferencesKey(DatastoreKeys.preferredTheme) - preferences[key] ?: "System" + override fun getBoolean(key: String): Flow { + 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 = context.dataStore.data.map { preferences -> + val key = stringPreferencesKey(omnivoreAuthToken) + val token = preferences[key] + token != null + } + + override val themeKeyFlow: Flow = context.dataStore.data.map { preferences -> + val key = stringPreferencesKey(preferredTheme) + preferences[key] ?: "System" + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/BasePreferenceWidget.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/BasePreferenceWidget.kt new file mode 100644 index 000000000..2a02183e5 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/BasePreferenceWidget.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/SwitchPreferenceWidget.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/SwitchPreferenceWidget.kt new file mode 100644 index 000000000..616e39530 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/SwitchPreferenceWidget.kt @@ -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 = {}, + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/TextPreferenceWidget.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/TextPreferenceWidget.kt new file mode 100644 index 000000000..f2fc7e3e7 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/component/TextPreferenceWidget.kt @@ -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 = {}, + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/icon/OmnivoreIcons.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/icon/OmnivoreIcons.kt new file mode 100644 index 000000000..1eb77226b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/icon/OmnivoreIcons.kt @@ -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 +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt new file mode 100644 index 000000000..2ef6bcdaf --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt @@ -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 + ) +) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt new file mode 100644 index 000000000..60c43882b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt @@ -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 +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Constants.kt new file mode 100644 index 000000000..850706c17 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Constants.kt @@ -0,0 +1,3 @@ +package app.omnivore.omnivore.core.designsystem.util + +const val SecondaryItemAlpha = .78f diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Modifier.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Modifier.kt new file mode 100644 index 000000000..a9487bc12 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Modifier.kt @@ -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) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/Networker.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/Networker.kt index 32bdb6e16..4bf66f76d 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/Networker.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/Networker.kt @@ -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() diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/ReadingProgressMutations.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/ReadingProgressMutations.kt index 1e33d801b..86fa5ffe5 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/ReadingProgressMutations.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/ReadingProgressMutations.kt @@ -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 + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelMutations.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelMutations.kt index a1d7df7d1..12a2d0c8a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelMutations.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelMutations.kt @@ -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? { - 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 + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelQuery.kt index 27c21a8aa..4b07f7f10 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemLabelQuery.kt @@ -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 { - 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() - } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt index 856b1d8ea..f463890ca 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SavedItemQuery.kt @@ -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, - val labels: List, - val state: String + val item: SavedItem?, + val highlights: List, + val labels: List, + 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 = "") - } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt index 4694dd48d..93307ddd0 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/network/SearchQuery.kt @@ -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 + val cursor: String?, val items: List ) data class LibrarySearchItem( - val item: SavedItem, - val labels: List, - val highlights: List + val item: SavedItem, val labels: List, val highlights: List ) 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()) + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DaosModule.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DaosModule.kt index f2175bb1f..aa11efbb8 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DaosModule.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/di/DaosModule.kt @@ -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() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt index 7ced32d09..7c10ed550 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt @@ -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(null) - private set + var errorMessage by mutableStateOf(null) + private set - var hasValidUsername by mutableStateOf(false) - private set + var hasValidUsername by mutableStateOf(false) + private set - var usernameValidationErrorMessage by mutableStateOf(null) - private set + var usernameValidationErrorMessage by mutableStateOf(null) + private set - var pendingEmailUserCreds by mutableStateOf(null) - private set + var pendingEmailUserCreds by mutableStateOf(null) + private set - val hasAuthTokenLiveData: LiveData = datastoreRepo - .hasAuthTokenFlow - .distinctUntilChanged() - .asLiveData() + val hasAuthTokenLiveData: LiveData = + 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 = 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) { + 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) { - 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) - } - } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/editinfo/EditInfoViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/editinfo/EditInfoViewModel.kt index befbe8f67..bcd64b9fc 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/editinfo/EditInfoViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/editinfo/EditInfoViewModel.kt @@ -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?) { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt new file mode 100644 index 000000000..0ad2fe432 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt @@ -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 + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingViewModel.kt new file mode 100644 index 000000000..8c63aef4a --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingViewModel.kt @@ -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(capacity = Channel.UNLIMITED) + private var librarySearchCursor: String? = null + + var snackbarMessage by mutableStateOf(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 = _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(null) + + val labelsState = libraryRepository.getSavedItemsLabels().stateIn( + scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf() + ) + + val activeLabels = MutableStateFlow>(listOf()) + + override val actionsMenuItemLiveData = MutableLiveData(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) { + 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) { + 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, + ) : FollowingUiState + + data object Error : FollowingUiState +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt index c9b1fa236..fa02563ce 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryFilterBar.kt @@ -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, + setBottomSheetState: (LibraryBottomSheetState) -> Unit, + updateSavedItemFilter: (SavedItemFilter) -> Unit, + updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit, + updateAppliedLabels: (List) -> Unit ) { + var isSavedItemFilterMenuExpanded by remember { mutableStateOf(false) } - val activeSavedItemFilter: SavedItemFilter by viewModel.appliedFilterLiveData.observeAsState( - SavedItemFilter.INBOX - ) - val activeLabels: List 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) }) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryNavigationBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryNavigationBar.kt index 74ae4438d..ea030c007 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryNavigationBar.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryNavigationBar.kt @@ -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 - ) - } } } ) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt index d51d86552..b2c0c0b37 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt @@ -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, + currentSavedItemData: SavedItemWithLabelsAndHighlights?, labelsViewModel: LabelsViewModel, - onDismiss: () -> Unit = {} + onDismiss: () -> Unit = {}, + createNewSavedItemLabel: (String, String) -> Unit, + updateSavedItemLabels: (String, List) -> Unit, + activeLabels: List, + updateAppliedLabels: (List) -> Unit ) { ModalBottomSheet( onDismissRequest = { onDismiss() }, @@ -185,50 +219,39 @@ fun LabelBottomSheet( ), ) { - val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit() - val labels: List 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, + sortFilter: SavedItemSortFilter, + updateSavedItemFilter:(SavedItemFilter) -> Unit, + updateSavedItemSortFilter: (SavedItemSortFilter) -> Unit, + setBottomSheetState: (LibraryBottomSheetState) -> Unit, + updateAppliedLabels: (List) -> Unit, + isFollowingScreen: Boolean, + selectItem: () -> Unit, + savedItemViewModel: SavedItemViewModel, + refresh: () -> Unit, + onUnarchive: (String) -> Unit, + onArchive: (String) -> Unit, + onDelete: (String) -> Unit, paddingValues: PaddingValues, - uiState: LibraryUiState + items: List, + 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() } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt index f231db99e..0aa31d10e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryViewModel.kt @@ -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(capacity = Channel.UNLIMITED) @@ -63,8 +49,11 @@ class LibraryViewModel @Inject constructor( var snackbarMessage by mutableStateOf(null) private set + private val folders = MutableStateFlow(listOf()) + 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 = 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 = _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(null) - val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData() - val activeLabelsLiveData = MutableLiveData>(listOf()) + + val labelsState = libraryRepository.getSavedItemsLabels().stateIn( + scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf() + ) + + val activeLabels = MutableStateFlow>(listOf()) override val actionsMenuItemLiveData = MutableLiveData(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) { 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) { + fun updateSavedItemLabels(savedItemId: String, labels: List) { 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 } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemFilter.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemFilter.kt index 4949c3f2e..89d0c0d3d 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemFilter.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemFilter.kt @@ -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) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt index 559caa299..92f5dc060 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SavedItemViewModel.kt @@ -8,5 +8,5 @@ interface SavedItemViewModel { val actionsMenuItemLiveData: MutableLiveData get() = MutableLiveData(null) - fun handleSavedItemAction(itemID: String, action: SavedItemAction) + fun handleSavedItemAction(itemId: String, action: SavedItemAction) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt index 3df01dea5..9a77a9cca 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt @@ -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) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt index 1ad2360cb..6a0240a27 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchViewModel.kt @@ -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) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/LogoutDialog.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/LogoutDialog.kt similarity index 97% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/LogoutDialog.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/LogoutDialog.kt index 02ca000c8..f48ead0dc 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/LogoutDialog.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/LogoutDialog.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt new file mode 100644 index 000000000..3f1d9977b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt @@ -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 + } + } +} + diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/SettingsViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileViewModel.kt similarity index 88% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/SettingsViewModel.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileViewModel.kt index 0058ba321..10dce3605 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/SettingsViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileViewModel.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/about/AboutScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/about/AboutScreen.kt similarity index 75% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/about/AboutScreen.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/about/AboutScreen.kt index 13ee241d3..014eaf8bb 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/about/AboutScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/about/AboutScreen.kt @@ -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 = { }, ) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/about/LogoHeader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/about/LogoHeader.kt similarity index 95% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/about/LogoHeader.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/about/LogoHeader.kt index 9f2a77933..b82824bbe 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/about/LogoHeader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/about/LogoHeader.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/account/AccountScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/account/AccountScreen.kt similarity index 88% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/account/AccountScreen.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/account/AccountScreen.kt index bb1988e84..95dc6c550 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/account/AccountScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/account/AccountScreen.kt @@ -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() }, ) } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/filters/FiltersScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/filters/FiltersScreen.kt new file mode 100644 index 000000000..a75f7e0f2 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/filters/FiltersScreen.kt @@ -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) }, + ) + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/filters/FiltersViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/filters/FiltersViewModel.kt new file mode 100644 index 000000000..93f6ea569 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/filters/FiltersViewModel.kt @@ -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 = datastoreRepository.getBoolean(followingTabActive).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false + ) + + fun setFollowingTabActiveState(value: Boolean) { + viewModelScope.launch { + datastoreRepository.putBoolean(followingTabActive, value) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/AnnotationEditView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/AnnotationEditView.kt index 85bc28d38..3cad00f6a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/AnnotationEditView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/AnnotationEditView.kt @@ -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) + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/LabelUtils.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/LabelUtils.kt similarity index 98% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/LabelUtils.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/LabelUtils.kt index 0826d78b2..fb861fda2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/LabelUtils.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/LabelUtils.kt @@ -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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt index 74d55d4ce..c727f278a 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/reader/WebReaderViewModel.kt @@ -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 = diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt index c56eb5557..4c4df4f30 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt @@ -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, + 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 diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveViewModel.kt index f2dba6cf7..b708c8b04 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/save/SaveViewModel.kt @@ -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) } /** diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt index 6e802ce98..134cb9bed 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/savedItemViews/SavedItemCard.kt @@ -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) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/SettingsScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/SettingsScreen.kt deleted file mode 100644 index 4d99f2bdb..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/settings/SettingsScreen.kt +++ /dev/null @@ -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" diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt index 53a39b62d..d317fdc37 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt @@ -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") } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/TopLevelDestination.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/TopLevelDestination.kt new file mode 100644 index 000000000..023af4646 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/TopLevelDestination.kt @@ -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 + ), +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt index 684cbdd3b..054bdf646 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/Constants.kt @@ -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" } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/ResourceProvider.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/ResourceProvider.kt similarity index 91% rename from android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/ResourceProvider.kt rename to android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/ResourceProvider.kt index 9e5ed576c..eeba0a45f 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/ResourceProvider.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/utils/ResourceProvider.kt @@ -1,4 +1,4 @@ -package app.omnivore.omnivore.feature +package app.omnivore.omnivore.utils import android.content.Context import androidx.annotation.StringRes diff --git a/android/Omnivore/app/src/main/res/drawable/ic_bookmarks_rounded_empty.xml b/android/Omnivore/app/src/main/res/drawable/ic_bookmarks_rounded_empty.xml new file mode 100644 index 000000000..faef7c272 --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/ic_bookmarks_rounded_empty.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/Omnivore/app/src/main/res/drawable/ic_bookmarks_rounded_fill.xml b/android/Omnivore/app/src/main/res/drawable/ic_bookmarks_rounded_fill.xml new file mode 100644 index 000000000..6fa9b230a --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/ic_bookmarks_rounded_fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/Omnivore/app/src/main/res/drawable/ic_person_rounded_empty.xml b/android/Omnivore/app/src/main/res/drawable/ic_person_rounded_empty.xml new file mode 100644 index 000000000..6787e8bf0 --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/ic_person_rounded_empty.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/Omnivore/app/src/main/res/drawable/ic_person_rounded_fill.xml b/android/Omnivore/app/src/main/res/drawable/ic_person_rounded_fill.xml new file mode 100644 index 000000000..6fcf704ac --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/ic_person_rounded_fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/Omnivore/app/src/main/res/drawable/ic_stacks_rounded_empty.xml b/android/Omnivore/app/src/main/res/drawable/ic_stacks_rounded_empty.xml new file mode 100644 index 000000000..b733c3549 --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/ic_stacks_rounded_empty.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/Omnivore/app/src/main/res/drawable/ic_stacks_rounded_fill.xml b/android/Omnivore/app/src/main/res/drawable/ic_stacks_rounded_fill.xml new file mode 100644 index 000000000..354b1ff31 --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/ic_stacks_rounded_fill.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/Omnivore/app/src/main/res/values-de/strings.xml b/android/Omnivore/app/src/main/res/values-de/strings.xml index 22f8bfcdc..6a26fea50 100644 --- a/android/Omnivore/app/src/main/res/values-de/strings.xml +++ b/android/Omnivore/app/src/main/res/values-de/strings.xml @@ -201,13 +201,13 @@ Einstellungen - Einstellungen - Dokumentation - Feedback - Datenschutzerklärung - Nutzungsbedingungen - Konto verwalten - Abmelden + Einstellungen + Dokumentation + Feedback + Datenschutzerklärung + Nutzungsbedingungen + Konto verwalten + Abmelden Link hinzufügen @@ -231,4 +231,4 @@ Abbrechen Fehler beim Bearbeiten des Artikels! Artikelinformationen erfolgreich aktualisiert! - \ No newline at end of file + diff --git a/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml b/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml index b198f4bd1..4086cda39 100644 --- a/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/Omnivore/app/src/main/res/values-zh-rCN/strings.xml @@ -201,13 +201,13 @@ 设定 - 设定 - 文件 - 反馈 - 隐私策略 - 条款和条件 - 管理帐户 - 登出 + 设定 + 文件 + 反馈 + 隐私策略 + 条款和条件 + 管理帐户 + 登出 收集箱 diff --git a/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml b/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml index f43bba4e3..c56adc88e 100644 --- a/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/Omnivore/app/src/main/res/values-zh-rTW/strings.xml @@ -200,11 +200,11 @@ 設定 - 設定 - 文件 - 回饋 - 隱私政策 - 條款和條件 - 管理帳戶 - 登出 + 設定 + 文件 + 回饋 + 隱私政策 + 條款和條件 + 管理帳戶 + 登出 diff --git a/android/Omnivore/app/src/main/res/values/strings.xml b/android/Omnivore/app/src/main/res/values/strings.xml index d74a443c4..d190f4f75 100644 --- a/android/Omnivore/app/src/main/res/values/strings.xml +++ b/android/Omnivore/app/src/main/res/values/strings.xml @@ -14,6 +14,11 @@ Note Back + + Following + Inbox + Profile + Continue with Apple Signing in... @@ -197,6 +202,9 @@ Confirm Cancel + + Hide following tab + Manage Account Reset Data Cache @@ -205,13 +213,15 @@ Settings - Settings - Documentation - Feedback - Privacy Policy - Terms and Conditions - Manage Account - Logout + Profile + Documentation + Feedback + Privacy Policy + Terms and Conditions + + Filters + Manage Account + Logout About diff --git a/android/Omnivore/gradle/libs.versions.toml b/android/Omnivore/gradle/libs.versions.toml index b78b50a66..3ab32681d 100644 --- a/android/Omnivore/gradle/libs.versions.toml +++ b/android/Omnivore/gradle/libs.versions.toml @@ -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" } diff --git a/android/Omnivore/gradle/wrapper/gradle-wrapper.properties b/android/Omnivore/gradle/wrapper/gradle-wrapper.properties index 490da3e99..9cbc7cf15 100644 --- a/android/Omnivore/gradle/wrapper/gradle-wrapper.properties +++ b/android/Omnivore/gradle/wrapper/gradle-wrapper.properties @@ -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