diff --git a/android/Omnivore/app/build.gradle b/android/Omnivore/app/build.gradle index a864109ad..67db8bfc6 100644 --- a/android/Omnivore/app/build.gradle +++ b/android/Omnivore/app/build.gradle @@ -17,8 +17,8 @@ android { applicationId "app.omnivore.omnivore" minSdk 26 targetSdk 33 - versionCode 33 - versionName "0.0.33" + versionCode 44 + versionName "0.0.44" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/android/Omnivore/app/src/main/graphql/ArticleContent.graphql b/android/Omnivore/app/src/main/graphql/ArticleContent.graphql index 350999bf7..ea8a8380f 100644 --- a/android/Omnivore/app/src/main/graphql/ArticleContent.graphql +++ b/android/Omnivore/app/src/main/graphql/ArticleContent.graphql @@ -40,6 +40,7 @@ fragment ArticleFields on Article { readAt updatedAt content + wordsCount } fragment HighlightFields on Highlight { diff --git a/android/Omnivore/app/src/main/graphql/Search.graphql b/android/Omnivore/app/src/main/graphql/Search.graphql index 7b231bafb..e35140fc2 100644 --- a/android/Omnivore/app/src/main/graphql/Search.graphql +++ b/android/Omnivore/app/src/main/graphql/Search.graphql @@ -22,9 +22,10 @@ query Search($after: String, $first: Int, $query: String) { originalArticleUrl uploadFileId labels { - id - name - color + ...LabelFields + } + highlights { + ...HighlightFields } pageId shortId @@ -36,6 +37,7 @@ query Search($after: String, $first: Int, $query: String) { readAt savedAt updatedAt + wordsCount } } pageInfo { diff --git a/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql b/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql index e7426f016..aa93b28d5 100644 --- a/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql +++ b/android/Omnivore/app/src/main/graphql/UpdatesSince.graphql @@ -24,9 +24,10 @@ query UpdatesSince($after: String, $first: Int, $since: Date!) { originalArticleUrl uploadFileId labels { - id - name - color + ...LabelFields + } + highlights { + ...HighlightFields } pageId shortId @@ -39,6 +40,7 @@ query UpdatesSince($after: String, $first: Int, $since: Date!) { savedAt updatedAt language + wordsCount } } pageInfo { diff --git a/android/Omnivore/app/src/main/graphql/schema.graphqls b/android/Omnivore/app/src/main/graphql/schema.graphqls index a0d0e1161..420960b75 100644 --- a/android/Omnivore/app/src/main/graphql/schema.graphqls +++ b/android/Omnivore/app/src/main/graphql/schema.graphqls @@ -1,4 +1,4 @@ -directive @sanitize(allowedTags: [String], maxLength: Int, pattern: String) on INPUT_FIELD_DEFINITION +directive @sanitize(allowedTags: [String], maxLength: Int, minLength: Int, pattern: String) on INPUT_FIELD_DEFINITION type AddPopularReadError { errorCodes: [AddPopularReadErrorCode!]! @@ -86,6 +86,8 @@ type Article { readAt: Date readingProgressAnchorIndex: Int! readingProgressPercent: Float! + readingProgressTopPercent: Float + recommendations: [Recommendation!] savedAt: Date! savedByViewer: Boolean shareInfo: LinkShareInfo @@ -101,6 +103,7 @@ type Article { updatedAt: Date! uploadFileId: ID url: String! + wordsCount: Int } type ArticleEdge { @@ -132,6 +135,7 @@ type ArticleSavingRequest { slug: String! status: ArticleSavingRequestStatus! updatedAt: Date! + url: String! user: User! userId: ID! @deprecated(reason: "userId has been replaced with user") } @@ -141,6 +145,7 @@ type ArticleSavingRequestError { } enum ArticleSavingRequestErrorCode { + BAD_DATA NOT_FOUND UNAUTHORIZED } @@ -148,6 +153,7 @@ enum ArticleSavingRequestErrorCode { union ArticleSavingRequestResult = ArticleSavingRequestError | ArticleSavingRequestSuccess enum ArticleSavingRequestStatus { + ARCHIVED DELETED FAILED PROCESSING @@ -177,7 +183,27 @@ type ArticlesSuccess { pageInfo: PageInfo! } +type BulkActionError { + errorCodes: [BulkActionErrorCode!]! +} + +enum BulkActionErrorCode { + UNAUTHORIZED +} + +union BulkActionResult = BulkActionError | BulkActionSuccess + +type BulkActionSuccess { + success: Boolean! +} + +enum BulkActionType { + ARCHIVE + DELETE +} + enum ContentReader { + EPUB PDF WEB } @@ -198,9 +224,11 @@ enum CreateArticleErrorCode { input CreateArticleInput { articleSavingRequestId: ID + labels: [CreateLabelInput!] preparedDocument: PreparedDocumentInput skipParsing: Boolean source: String + state: ArticleSavingRequestStatus uploadFileId: ID url: String! } @@ -232,6 +260,31 @@ type CreateArticleSuccess { user: User! } +type CreateGroupError { + errorCodes: [CreateGroupErrorCode!]! +} + +enum CreateGroupErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +input CreateGroupInput { + description: String + expiresInDays: Int + maxMembers: Int + name: String! + onlyAdminCanPost: Boolean + onlyAdminCanSeeMembers: Boolean + topics: [String!] +} + +union CreateGroupResult = CreateGroupError | CreateGroupSuccess + +type CreateGroupSuccess { + group: RecommendationGroup! +} + type CreateHighlightError { errorCodes: [CreateHighlightErrorCode!]! } @@ -247,13 +300,17 @@ enum CreateHighlightErrorCode { input CreateHighlightInput { annotation: String articleId: ID! + highlightPositionAnchorIndex: Int + highlightPositionPercent: Float + html: String id: ID! - patch: String! + patch: String prefix: String - quote: String! + quote: String sharedAt: Date shortId: String! suffix: String + type: HighlightType } type CreateHighlightReplyError { @@ -296,7 +353,7 @@ enum CreateLabelErrorCode { } input CreateLabelInput { - color: String! + color: String description: String name: String! } @@ -388,6 +445,22 @@ type DeleteAccountSuccess { userID: ID! } +type DeleteFilterError { + errorCodes: [DeleteFilterErrorCode!]! +} + +enum DeleteFilterErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union DeleteFilterResult = DeleteFilterError | DeleteFilterSuccess + +type DeleteFilterSuccess { + filter: Filter! +} + type DeleteHighlightError { errorCodes: [DeleteHighlightErrorCode!]! } @@ -500,6 +573,22 @@ type DeleteReminderSuccess { reminder: Reminder! } +type DeleteRuleError { + errorCodes: [DeleteRuleErrorCode!]! +} + +enum DeleteRuleErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union DeleteRuleResult = DeleteRuleError | DeleteRuleSuccess + +type DeleteRuleSuccess { + rule: Rule! +} + type DeleteWebhookError { errorCodes: [DeleteWebhookErrorCode!]! } @@ -522,6 +611,31 @@ type DeviceToken { token: String! } +type DeviceTokensError { + errorCodes: [DeviceTokensErrorCode!]! +} + +enum DeviceTokensErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union DeviceTokensResult = DeviceTokensError | DeviceTokensSuccess + +type DeviceTokensSuccess { + deviceTokens: [DeviceToken!]! +} + +type Feature { + createdAt: Date! + expiresAt: Date + grantedAt: Date + id: ID! + name: String! + token: String! + updatedAt: Date! +} + type FeedArticle { annotationsCount: Int article: Article! @@ -555,6 +669,31 @@ type FeedArticlesSuccess { pageInfo: PageInfo! } +type Filter { + createdAt: Date! + description: String + filter: String! + id: ID! + name: String! + position: Int! + updatedAt: Date! +} + +type FiltersError { + errorCodes: [FiltersErrorCode!]! +} + +enum FiltersErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union FiltersResult = FiltersError | FiltersSuccess + +type FiltersSuccess { + filters: [Filter!]! +} + type GenerateApiKeyError { errorCodes: [GenerateApiKeyErrorCode!]! } @@ -644,19 +783,39 @@ type GoogleSignupSuccess { me: User! } +type GroupsError { + errorCodes: [GroupsErrorCode!]! +} + +enum GroupsErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union GroupsResult = GroupsError | GroupsSuccess + +type GroupsSuccess { + groups: [RecommendationGroup!]! +} + type Highlight { annotation: String createdAt: Date! createdByMe: Boolean! + highlightPositionAnchorIndex: Int + highlightPositionPercent: Float + html: String id: ID! - patch: String! + labels: [Label!] + patch: String prefix: String - quote: String! + quote: String reactions: [Reaction!]! replies: [HighlightReply!]! sharedAt: Date shortId: String! suffix: String + type: HighlightType! updatedAt: Date! user: User! } @@ -674,17 +833,40 @@ type HighlightStats { highlightCount: Int! } +enum HighlightType { + HIGHLIGHT + NOTE + REDACTION +} + +type ImportFromIntegrationError { + errorCodes: [ImportFromIntegrationErrorCode!]! +} + +enum ImportFromIntegrationErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union ImportFromIntegrationResult = ImportFromIntegrationError | ImportFromIntegrationSuccess + +type ImportFromIntegrationSuccess { + success: Boolean! +} + type Integration { createdAt: Date! enabled: Boolean! id: ID! + name: String! token: String! type: IntegrationType! updatedAt: Date! } enum IntegrationType { - READWISE + EXPORT + IMPORT } type IntegrationsError { @@ -702,6 +884,22 @@ type IntegrationsSuccess { integrations: [Integration!]! } +type JoinGroupError { + errorCodes: [JoinGroupErrorCode!]! +} + +enum JoinGroupErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union JoinGroupResult = JoinGroupError | JoinGroupSuccess + +type JoinGroupSuccess { + group: RecommendationGroup! +} + type Label { color: String! createdAt: Date @@ -727,6 +925,22 @@ type LabelsSuccess { labels: [Label!]! } +type LeaveGroupError { + errorCodes: [LeaveGroupErrorCode!]! +} + +enum LeaveGroupErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union LeaveGroupResult = LeaveGroupError | LeaveGroupSuccess + +type LeaveGroupSuccess { + success: Boolean! +} + type Link { highlightStats: HighlightStats! id: ID! @@ -782,6 +996,22 @@ type LoginSuccess { me: User! } +type MarkEmailAsItemError { + errorCodes: [MarkEmailAsItemErrorCode!]! +} + +enum MarkEmailAsItemErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union MarkEmailAsItemResult = MarkEmailAsItemError | MarkEmailAsItemSuccess + +type MarkEmailAsItemSuccess { + success: Boolean! +} + type MergeHighlightError { errorCodes: [MergeHighlightErrorCode!]! } @@ -797,6 +1027,9 @@ enum MergeHighlightErrorCode { input MergeHighlightInput { annotation: String articleId: ID! + highlightPositionAnchorIndex: Int + highlightPositionPercent: Float + html: String id: ID! overlapHighlightIdList: [String!]! patch: String! @@ -813,6 +1046,27 @@ type MergeHighlightSuccess { overlapHighlightIdList: [String!]! } +type MoveFilterError { + errorCodes: [MoveFilterErrorCode!]! +} + +enum MoveFilterErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input MoveFilterInput { + afterFilterId: ID + filterId: ID! +} + +union MoveFilterResult = MoveFilterError | MoveFilterSuccess + +type MoveFilterSuccess { + filter: Filter! +} + type MoveLabelError { errorCodes: [MoveLabelErrorCode!]! } @@ -836,8 +1090,10 @@ type MoveLabelSuccess { type Mutation { addPopularRead(name: String!): AddPopularReadResult! + bulkAction(action: BulkActionType!, query: String): BulkActionResult! createArticle(input: CreateArticleInput!): CreateArticleResult! createArticleSavingRequest(input: CreateArticleSavingRequestInput!): CreateArticleSavingRequestResult! + createGroup(input: CreateGroupInput!): CreateGroupResult! createHighlight(input: CreateHighlightInput!): CreateHighlightResult! createHighlightReply(input: CreateHighlightReplyInput!): CreateHighlightReplyResult! createLabel(input: CreateLabelInput!): CreateLabelResult! @@ -845,6 +1101,7 @@ type Mutation { createReaction(input: CreateReactionInput!): CreateReactionResult! createReminder(input: CreateReminderInput!): CreateReminderResult! deleteAccount(userID: ID!): DeleteAccountResult! + deleteFilter(id: ID!): DeleteFilterResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! deleteIntegration(id: ID!): DeleteIntegrationResult! @@ -852,17 +1109,27 @@ type Mutation { deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! deleteReaction(id: ID!): DeleteReactionResult! deleteReminder(id: ID!): DeleteReminderResult! + deleteRule(id: ID!): DeleteRuleResult! deleteWebhook(id: ID!): DeleteWebhookResult! 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! + 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! @@ -872,6 +1139,7 @@ type Mutation { setLabels(input: SetLabelsInput!): SetLabelsResult! setLabelsForHighlight(input: SetLabelsForHighlightInput!): SetLabelsResult! setLinkArchived(input: ArchiveLinkInput!): ArchiveLinkResult! + setRule(input: SetRuleInput!): SetRuleResult! setShareArticle(input: SetShareArticleInput!): SetShareArticleResult! setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult! setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! @@ -888,12 +1156,15 @@ type Mutation { 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! id: ID! + subscriptionCount: Int! } type NewsletterEmailsError { @@ -911,6 +1182,25 @@ type NewsletterEmailsSuccess { newsletterEmails: [NewsletterEmail!]! } +type OptInFeatureError { + errorCodes: [OptInFeatureErrorCode!]! +} + +enum OptInFeatureErrorCode { + BAD_REQUEST + NOT_FOUND +} + +input OptInFeatureInput { + name: String! +} + +union OptInFeatureResult = OptInFeatureError | OptInFeatureSuccess + +type OptInFeatureSuccess { + feature: Feature! +} + type Page { author: String createdAt: Date! @@ -951,11 +1241,29 @@ enum PageType { 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! +} + input PreparedDocumentInput { document: String! pageInfo: PageInfoInput! @@ -971,25 +1279,31 @@ type Profile { type Query { apiKeys: ApiKeysResult! - article(slug: String!, username: String!): ArticleResult! - articleSavingRequest(id: ID!): ArticleSavingRequestResult! + article(format: String, slug: String!, username: String!): ArticleResult! + articleSavingRequest(id: ID, url: String): ArticleSavingRequestResult! articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult! + deviceTokens: DeviceTokensResult! feedArticles(after: String, first: Int, sharedByUser: ID, sort: SortParams): FeedArticlesResult! + filters: FiltersResult! getFollowers(userId: ID): GetFollowersResult! getFollowing(userId: ID): GetFollowingResult! getUserPersonalization: GetUserPersonalizationResult! + groups: GroupsResult! hello: String integrations: IntegrationsResult! labels: LabelsResult! me: User newsletterEmails: NewsletterEmailsResult! + recentEmails: RecentEmailsResult! + recentSearches: RecentSearchesResult! reminder(linkId: ID!): ReminderResult! - search(after: String, first: Int, query: String): SearchResult! + rules(enabled: Boolean): RulesResult! + search(after: String, first: Int, format: String, includeContent: Boolean, query: String): SearchResult! sendInstallInstructions: SendInstallInstructionsResult! sharedArticle(selectedHighlightId: String, slug: String!, username: String!): SharedArticleResult! subscriptions(sort: SortParams): SubscriptionsResult! typeaheadSearch(first: Int, query: String!): TypeaheadSearchResult! - updatesSince(after: String, first: Int, since: Date!): UpdatesSinceResult! + updatesSince(after: String, first: Int, since: Date!, sort: SortParams): UpdatesSinceResult! user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! @@ -1021,6 +1335,128 @@ type ReadState { readingTime: Int } +type RecentEmail { + createdAt: Date! + from: String! + html: String + id: ID! + subject: String! + text: String! + to: String! + type: String! +} + +type RecentEmailsError { + errorCodes: [RecentEmailsErrorCode!]! +} + +enum RecentEmailsErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union RecentEmailsResult = RecentEmailsError | RecentEmailsSuccess + +type RecentEmailsSuccess { + recentEmails: [RecentEmail!]! +} + +type RecentSearch { + createdAt: Date! + id: ID! + term: String! +} + +type RecentSearchesError { + errorCodes: [RecentSearchesErrorCode!]! +} + +enum RecentSearchesErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union RecentSearchesResult = RecentSearchesError | RecentSearchesSuccess + +type RecentSearchesSuccess { + searches: [RecentSearch!]! +} + +type RecommendError { + errorCodes: [RecommendErrorCode!]! +} + +enum RecommendErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +type RecommendHighlightsError { + errorCodes: [RecommendHighlightsErrorCode!]! +} + +enum RecommendHighlightsErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input RecommendHighlightsInput { + groupIds: [ID!]! + highlightIds: [ID!]! + note: String + pageId: ID! +} + +union RecommendHighlightsResult = RecommendHighlightsError | RecommendHighlightsSuccess + +type RecommendHighlightsSuccess { + success: Boolean! +} + +input RecommendInput { + groupIds: [ID!]! + note: String + pageId: ID! + recommendedWithHighlights: Boolean +} + +union RecommendResult = RecommendError | RecommendSuccess + +type RecommendSuccess { + success: Boolean! +} + +type Recommendation { + 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! +} + +type RecommendingUser { + name: String! + profileImageURL: String + userId: String! + username: String! +} + type Reminder { archiveUntil: Boolean! id: ID! @@ -1079,6 +1515,48 @@ type RevokeApiKeySuccess { apiKey: ApiKey! } +type Rule { + actions: [RuleAction!]! + createdAt: Date! + enabled: Boolean! + filter: String! + id: ID! + name: String! + updatedAt: Date! +} + +type RuleAction { + params: [String!]! + type: RuleActionType! +} + +input RuleActionInput { + params: [String!]! + type: RuleActionType! +} + +enum RuleActionType { + ADD_LABEL + ARCHIVE + MARK_AS_READ + SEND_NOTIFICATION +} + +type RulesError { + errorCodes: [RulesErrorCode!]! +} + +enum RulesErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union RulesResult = RulesError | RulesSuccess + +type RulesSuccess { + rules: [Rule!]! +} + type SaveArticleReadingProgressError { errorCodes: [SaveArticleReadingProgressErrorCode!]! } @@ -1093,6 +1571,7 @@ input SaveArticleReadingProgressInput { id: ID! readingProgressAnchorIndex: Int! readingProgressPercent: Float! + readingProgressTopPercent: Float } union SaveArticleReadingProgressResult = SaveArticleReadingProgressError | SaveArticleReadingProgressSuccess @@ -1107,21 +1586,50 @@ type SaveError { } enum SaveErrorCode { + EMBEDDED_HIGHLIGHT_FAILED UNAUTHORIZED UNKNOWN } input SaveFileInput { clientRequestId: ID! + labels: [CreateLabelInput!] source: String! + state: ArticleSavingRequestStatus uploadFileId: ID! url: String! } +type SaveFilterError { + errorCodes: [SaveFilterErrorCode!]! +} + +enum SaveFilterErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input SaveFilterInput { + description: String + filter: String! + id: ID + name: String! +} + +union SaveFilterResult = SaveFilterError | SaveFilterSuccess + +type SaveFilterSuccess { + filter: Filter! +} + input SavePageInput { clientRequestId: ID! + labels: [CreateLabelInput!] originalContent: String! + parseResult: ParseResult source: String! + state: ArticleSavingRequestStatus title: String url: String! } @@ -1135,7 +1643,9 @@ type SaveSuccess { input SaveUrlInput { clientRequestId: ID! + labels: [CreateLabelInput!] source: String! + state: ArticleSavingRequestStatus url: String! } @@ -1144,12 +1654,14 @@ type SearchError { } enum SearchErrorCode { + QUERY_TOO_LONG UNAUTHORIZED } type SearchItem { annotation: String author: String + content: String contentReader: ContentReader! createdAt: Date! description: String @@ -1168,8 +1680,11 @@ type SearchItem { readAt: Date readingProgressAnchorIndex: Int! readingProgressPercent: Float! + readingProgressTopPercent: Float + recommendations: [Recommendation!] savedAt: Date! shortId: String + siteIcon: String siteName: String slug: String! state: ArticleSavingRequestStatus @@ -1180,6 +1695,7 @@ type SearchItem { updatedAt: Date uploadFileId: ID url: String! + wordsCount: Int } type SearchItemEdge { @@ -1287,8 +1803,9 @@ enum SetIntegrationErrorCode { input SetIntegrationInput { enabled: Boolean! id: ID + name: String! token: String! - type: IntegrationType! + type: IntegrationType } union SetIntegrationResult = SetIntegrationError | SetIntegrationSuccess @@ -1323,6 +1840,31 @@ type SetLabelsSuccess { labels: [Label!]! } +type SetRuleError { + errorCodes: [SetRuleErrorCode!]! +} + +enum SetRuleErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +input SetRuleInput { + actions: [RuleActionInput!]! + description: String + enabled: Boolean! + filter: String! + id: ID + name: String! +} + +union SetRuleResult = SetRuleError | SetRuleSuccess + +type SetRuleSuccess { + rule: Rule! +} + type SetShareArticleError { errorCodes: [SetShareArticleErrorCode!]! } @@ -1373,6 +1915,7 @@ type SetUserPersonalizationError { } enum SetUserPersonalizationErrorCode { + NOT_FOUND UNAUTHORIZED } @@ -1382,6 +1925,10 @@ input SetUserPersonalizationInput { libraryLayoutType: String librarySortOrder: SortOrder margin: Int + speechRate: String + speechSecondaryVoice: String + speechVoice: String + speechVolume: String theme: String } @@ -1485,6 +2032,7 @@ type SubscribeSuccess { type Subscription { createdAt: Date! description: String + icon: String id: ID! name: String! newsletterEmail: String! @@ -1576,6 +2124,8 @@ enum UpdateHighlightErrorCode { input UpdateHighlightInput { annotation: String highlightId: ID! + html: String + quote: String sharedAt: Date } @@ -1664,8 +2214,11 @@ enum UpdatePageErrorCode { } input UpdatePageInput { + byline: String description: String pageId: ID! + publishedAt: Date + savedAt: Date title: String } @@ -1818,6 +2371,28 @@ enum UploadFileStatus { INITIALIZED } +type UploadImportFileError { + errorCodes: [UploadImportFileErrorCode!]! +} + +enum UploadImportFileErrorCode { + BAD_REQUEST + UNAUTHORIZED + UPLOAD_DAILY_LIMIT_EXCEEDED +} + +union UploadImportFileResult = UploadImportFileError | UploadImportFileSuccess + +type UploadImportFileSuccess { + uploadSignedUrl: String +} + +enum UploadImportFileType { + MATTER + POCKET + URL_LIST +} + type User { followersCount: Int friendsCount: Int @@ -1851,6 +2426,10 @@ type UserPersonalization { libraryLayoutType: String librarySortOrder: SortOrder margin: Int + speechRate: String + speechSecondaryVoice: String + speechVoice: String + speechVolume: String theme: String } 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 b37795e62..64c58df6c 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 @@ -15,6 +15,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import app.omnivore.omnivore.ui.auth.LoginViewModel import app.omnivore.omnivore.ui.library.LibraryViewModel +import app.omnivore.omnivore.ui.library.SearchViewModel import app.omnivore.omnivore.ui.root.RootView import app.omnivore.omnivore.ui.settings.SettingsViewModel import app.omnivore.omnivore.ui.theme.OmnivoreTheme @@ -33,6 +34,7 @@ class MainActivity : ComponentActivity() { val loginViewModel: LoginViewModel by viewModels() val libraryViewModel: LibraryViewModel by viewModels() val settingsViewModel: SettingsViewModel by viewModels() + val searchViewModel: SearchViewModel by viewModels() val context = this @@ -53,7 +55,7 @@ class MainActivity : ComponentActivity() { .fillMaxSize() .background(color = Color.Black) ) { - RootView(loginViewModel, libraryViewModel, settingsViewModel) + RootView(loginViewModel, searchViewModel, libraryViewModel, settingsViewModel) } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Routes.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Routes.kt index 0ab373cc9..6608a30f6 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Routes.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/Routes.kt @@ -3,6 +3,7 @@ package app.omnivore.omnivore sealed class Routes(val route: String) { object Library : Routes("Library") object Settings: Routes("Settings") + object Search: Routes("Search") object Documentation: Routes("Documentation") object PrivacyPolicy: Routes("PrivacyPolicy") object TermsAndConditions: Routes("TermsAndConditions") diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt index 6a354fed4..8b539b918 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/HighlightActionHandlers.kt @@ -15,10 +15,10 @@ suspend fun DataService.createWebHighlight(jsonString: String) { val highlight = Highlight( highlightId = createHighlightInput.id, shortId = createHighlightInput.shortId, - quote = createHighlightInput.quote, + quote = createHighlightInput.quote.getOrNull(), prefix = null, suffix = null, - patch = createHighlightInput.patch, + patch = createHighlightInput.patch.getOrNull(), annotation = createHighlightInput.annotation.getOrNull(), createdAt = null, updatedAt = null, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/LibrarySync.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/LibrarySync.kt index 43fffe011..d98fd27b2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/LibrarySync.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/LibrarySync.kt @@ -1,15 +1,23 @@ package app.omnivore.omnivore.dataService import android.util.Log +import androidx.room.PrimaryKey +import app.omnivore.omnivore.models.ServerSyncStatus import app.omnivore.omnivore.networking.* import app.omnivore.omnivore.persistence.entities.* -suspend fun DataService.librarySearch(cursor: String?, query: String): SavedItemSyncResult { +suspend fun DataService.librarySearch(cursor: String?, query: String): SearchResult { val searchResult = networker.search(cursor = cursor, limit = 10, query = query) - val savedItems = searchResult.items.map { it.item } + val savedItems = searchResult.items.map { + SavedItemWithLabelsAndHighlights( + savedItem = it.item, + labels = it.labels, + highlights = it.highlights, + ) + } - db.savedItemDao().insertAll(savedItems) + db.savedItemDao().insertAll(savedItems.map { it.savedItem }) val labels: MutableList = mutableListOf() val crossRefs: MutableList = mutableListOf() @@ -28,19 +36,20 @@ suspend fun DataService.librarySearch(cursor: String?, query: String): SavedItem db.savedItemLabelDao().insertAll(labels) db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs) - Log.d("sync", "found ${searchResult.items.size} items with search api. Query: $query") + Log.d("sync", "found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor") - return SavedItemSyncResult( + return SearchResult( hasError = false, hasMoreItems = false, cursor = searchResult.cursor, count = searchResult.items.size, - savedItemSlugs = savedItems.map { it.slug } + 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 val savedItems = syncResult.items.map { SavedItem( @@ -62,7 +71,8 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S slug = it.slug, isArchived = it.isArchived, contentReader = it.contentReader.rawValue, - content = null + content = null, + wordsCount = it.wordsCount ) } @@ -75,9 +85,9 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S for (item in syncResult.items) { val itemLabels = (item.labels ?: listOf()).map { SavedItemLabel( - savedItemLabelId = it.id, - name = it.name, - color = it.color, + savedItemLabelId = it.labelFields.id, + name = it.labelFields.name, + color = it.labelFields.color, createdAt = null, labelDescription = null ) @@ -86,7 +96,10 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S labels.addAll(itemLabels) val newCrossRefs = itemLabels.map { - SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = item.id) + SavedItemAndSavedItemLabelCrossRef( + savedItemLabelId = it.savedItemLabelId, + savedItemId = item.id + ) } crossRefs.addAll(newCrossRefs) @@ -95,6 +108,37 @@ suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): S db.savedItemLabelDao().insertAll(labels) db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs) + // Persist Highlights + db.highlightDao().insertAll(syncResult.items.flatMap { + it.highlights ?: listOf() + }.map { + Highlight( + highlightId = it.highlightFields.id, + annotation = it.highlightFields.annotation, + createdByMe = it.highlightFields.createdByMe, + markedForDeletion = false, + patch = it.highlightFields.patch, + prefix = it.highlightFields.prefix, + quote = it.highlightFields.quote, + serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue, + shortId = it.highlightFields.shortId, + suffix = it.highlightFields.suffix, + createdAt = null, + updatedAt = it.highlightFields.updatedAt as String?, + ) + }) + + val highlightCrossRefs = syncResult.items.flatMap { + val savedItem = it + (savedItem.highlights ?: listOf()).map { + Pair(it, savedItem.id) + } + }.map { + SavedItemAndHighlightCrossRef(highlightId = it.first.highlightFields.id, savedItemId = it.second) + } + + db.savedItemAndHighlightCrossRefDao().insertAll(highlightCrossRefs) + Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since") return SavedItemSyncResult( @@ -150,3 +194,15 @@ data class SavedItemSyncResult( 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? +) { + 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/dataService/SyncOfflineChanges.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/SyncOfflineChanges.kt index 829c9aa66..b1c949474 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/SyncOfflineChanges.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/dataService/SyncOfflineChanges.kt @@ -7,10 +7,13 @@ import app.omnivore.omnivore.networking.* import app.omnivore.omnivore.persistence.entities.Highlight import app.omnivore.omnivore.persistence.entities.SavedItem import com.apollographql.apollo3.api.Optional +import com.apollographql.apollo3.api.Optional.Companion.absent +import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.* suspend fun DataService.startSyncChannels() { for (savedItem in savedItemSyncChannel) { @@ -130,8 +133,8 @@ private suspend fun DataService.syncHighlight(highlight: Highlight) { annotation = Optional.presentIfNotNull(highlight.annotation), articleId = savedItemID ?: "", id = highlight.highlightId, - patch = highlight.patch ?: "", - quote = highlight.quote ?: "", + patch = Optional.presentIfNotNull(highlight.patch), + quote = Optional.presentIfNotNull(highlight.quote), shortId = highlight.shortId ?: "" ) ) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt index 97260c72d..baf676707 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt @@ -24,8 +24,8 @@ data class CreateHighlightParams( annotation = Optional.presentIfNotNull(`annotation`), articleId = articleId ?: "", id = id ?: "", - patch = patch ?: "", - quote = quote ?: "", + patch = Optional.presentIfNotNull(patch), + quote = Optional.presentIfNotNull(quote), shortId = shortId ?: "" ) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt index bc5ec8840..bfad2f85b 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SavedItemQuery.kt @@ -77,7 +77,8 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse { slug = article.articleFields.slug, isArchived = article.articleFields.isArchived, contentReader = article.articleFields.contentReader.rawValue, - content = article.articleFields.content + content = article.articleFields.content, + wordsCount = article.articleFields.wordsCount ) return SavedItemQueryResponse(item = savedItem, highlights, labels = savedItemLabels, state = article.articleFields.state?.rawValue ?: "") diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt index a1706493e..8f21c8e73 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt @@ -1,17 +1,12 @@ package app.omnivore.omnivore.networking +import androidx.room.PrimaryKey import app.omnivore.omnivore.graphql.generated.SearchQuery import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery -import app.omnivore.omnivore.persistence.entities.SavedItem -import app.omnivore.omnivore.persistence.entities.SavedItemCardData -import app.omnivore.omnivore.persistence.entities.SavedItemLabel +import app.omnivore.omnivore.models.ServerSyncStatus +import app.omnivore.omnivore.persistence.entities.* import com.apollographql.apollo3.api.Optional -data class SearchQueryResponse( - val cursor: String?, - val cardsData: List -) - data class LibrarySearchQueryResponse( val cursor: String?, val items: List @@ -19,39 +14,10 @@ data class LibrarySearchQueryResponse( data class LibrarySearchItem( val item: SavedItem, - val labels: List + val labels: List, + val highlights: List ) -suspend fun Networker.typeaheadSearch( - query: String -): SearchQueryResponse { - try { - val result = authenticatedApolloClient().query( - TypeaheadSearchQuery(query) - ).execute() - - val itemList = result.data?.typeaheadSearch?.onTypeaheadSearchSuccess?.items ?: listOf() - - val cardsData = itemList.map { - SavedItemCardData( - savedItemId = it.id, - slug = it.slug, - publisherURLString = "", - title = it.title, - author = "", - imageURLString = null, - isArchived = false, - pageURLString = "", - contentReader = null, - ) - } - - return SearchQueryResponse(null, cardsData) - } catch (e: java.lang.Exception) { - return SearchQueryResponse(null, listOf()) - } -} - suspend fun Networker.search( cursor: String? = null, limit: Int = 15, @@ -90,16 +56,32 @@ suspend fun Networker.search( slug = it.node.slug, isArchived = it.node.isArchived, contentReader = it.node.contentReader.rawValue, - content = null + content = null, + wordsCount = it.node.wordsCount, ), labels = (it.node.labels ?: listOf()).map { label -> SavedItemLabel( - savedItemLabelId = label.id, - name = label.name, - color = label.color, + savedItemLabelId = label.labelFields.id, + name = label.labelFields.name, + color = label.labelFields.color, createdAt = null, labelDescription = null ) + }, + highlights = (it.node.highlights ?: listOf()).map { highlight -> + Highlight( + highlightId = highlight.highlightFields.id, + 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 = null, + ) } ) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/TypeaheadSearch.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/TypeaheadSearch.kt new file mode 100644 index 000000000..48a61bafd --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/TypeaheadSearch.kt @@ -0,0 +1,39 @@ +package app.omnivore.omnivore.networking + +import app.omnivore.omnivore.graphql.generated.SearchQuery +import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery +import app.omnivore.omnivore.persistence.entities.SavedItem +import app.omnivore.omnivore.persistence.entities.SavedItemCardData +import app.omnivore.omnivore.persistence.entities.SavedItemLabel +import app.omnivore.omnivore.persistence.entities.TypeaheadCardData +import com.apollographql.apollo3.api.Optional + +data class SearchQueryResponse( + val cursor: String?, + val cardsData: List +) + +suspend fun Networker.typeaheadSearch( + query: String +): SearchQueryResponse { + try { + val result = authenticatedApolloClient().query( + TypeaheadSearchQuery(query) + ).execute() + + val itemList = result.data?.typeaheadSearch?.onTypeaheadSearchSuccess?.items ?: listOf() + + val cardsData = itemList.map { + TypeaheadCardData( + savedItemId = it.id, + slug = it.slug, + title = it.title, + isArchived = false, + ) + } + + return SearchQueryResponse(null, cardsData) + } catch (e: java.lang.Exception) { + return SearchQueryResponse(null, listOf()) + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/AppDatabase.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/AppDatabase.kt index 2557e4e7e..5d8322175 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/AppDatabase.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/AppDatabase.kt @@ -13,7 +13,7 @@ import app.omnivore.omnivore.persistence.entities.* SavedItemAndSavedItemLabelCrossRef::class, SavedItemAndHighlightCrossRef::class ], - version = 3 + version = 5 ) abstract class AppDatabase : RoomDatabase() { abstract fun viewerDao(): ViewerDao diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/Highlight.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/Highlight.kt index 757a8ee42..fa7b6a45e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/Highlight.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/Highlight.kt @@ -11,9 +11,9 @@ data class Highlight( val createdAt: String?, val createdByMe: Boolean, val markedForDeletion: Boolean = false, - var patch: String, + var patch: String?, var prefix: String?, - var quote: String, + var quote: String?, var serverSyncStatus: Int = ServerSyncStatus.IS_SYNCED.rawValue, var shortId: String, val suffix: String?, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/SavedItem.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/SavedItem.kt index 548adffb9..38cd552d6 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/SavedItem.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/persistence/entities/SavedItem.kt @@ -2,8 +2,10 @@ package app.omnivore.omnivore.persistence.entities import androidx.core.net.toUri import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.room.* import app.omnivore.omnivore.BuildConfig +import app.omnivore.omnivore.graphql.generated.SearchQuery import app.omnivore.omnivore.models.ServerSyncStatus import app.omnivore.omnivore.ui.library.SavedItemSortFilter import java.util.* @@ -40,7 +42,8 @@ data class SavedItem( val originalHtml: String? = null, @ColumnInfo(typeAffinity = ColumnInfo.BLOB) val pdfData: ByteArray? = null, var serverSyncStatus: Int = 0, - val tempPDFURL: String? = null + val tempPDFURL: String? = null, + val wordsCount: Int? = null // hasMany highlights // hasMany labels @@ -75,7 +78,10 @@ data class SavedItemCardData( val imageURLString: String?, val isArchived: Boolean, val pageURLString: String, - val contentReader: String? + val contentReader: String?, + val savedAt: String, + val readingProgress: Double, + val wordsCount: Int? ) { fun publisherDisplayName(): String? { return publisherURLString?.toUri()?.host @@ -87,6 +93,13 @@ data class SavedItemCardData( } } +data class TypeaheadCardData( + val savedItemId: String, + val slug: String, + val title: String, + val isArchived: Boolean, +) + @Dao interface SavedItemDao { @Query("SELECT * FROM savedItem") @@ -148,9 +161,73 @@ interface SavedItemDao { "ORDER BY publishDate DESC" ) fun getLibraryLiveDataSortedByRecentlyPublished(archiveFilter: Int): LiveData> + + @Transaction + @Query( + "SELECT ${SavedItemQueryConstants.libraryColumns} " + + "FROM SavedItem " + + "LEFT OUTER JOIN SavedItemAndSavedItemLabelCrossRef on SavedItem.savedItemId = SavedItemAndSavedItemLabelCrossRef.savedItemId " + + "LEFT OUTER JOIN SavedItemAndHighlightCrossRef on SavedItem.savedItemId = SavedItemAndHighlightCrossRef.savedItemId " + + + "LEFT OUTER JOIN SavedItemLabel on SavedItemLabel.savedItemLabelId = SavedItemAndSavedItemLabelCrossRef.savedItemLabelId " + + "LEFT OUTER JOIN Highlight on highlight.highlightId = SavedItemAndHighlightCrossRef.highlightId " + + + "WHERE SavedItem.serverSyncStatus != 2 " + + "AND SavedItem.isArchived != :archiveFilter " + + "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 " + + + "GROUP BY SavedItem.savedItemId " + + + "ORDER BY \n" + + "CASE WHEN :sortKey = 'newest' THEN SavedItem.savedAt END DESC,\n" + + "CASE WHEN :sortKey = 'oldest' THEN SavedItem.savedAt END ASC,\n" + + + "CASE WHEN :sortKey = 'recentlyRead' THEN SavedItem.readAt END DESC,\n" + + "CASE WHEN :sortKey = 'recentlyPublished' THEN SavedItem.publishDate END DESC" + ) + fun _filteredLibraryData(archiveFilter: Int, sortKey: String, hasRequiredLabels: Int, hasExcludedLabels: Int, requiredLabels: List, excludedLabels: List, allowedContentReaders: List): LiveData> + + fun filteredLibraryData(archiveFilter: Int, sortKey: String, requiredLabels: List, excludedLabels: List, allowedContentReaders: List): LiveData> { + return _filteredLibraryData( + archiveFilter = archiveFilter, + sortKey = sortKey, + hasRequiredLabels = requiredLabels.size, + hasExcludedLabels = excludedLabels.size, + requiredLabels = requiredLabels, + excludedLabels = excludedLabels, + allowedContentReaders = allowedContentReaders + ) + } } + + object SavedItemQueryConstants { - const val columns = "savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader " + const val columns = "savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader, savedAt, readingProgress, wordsCount" + const val libraryColumns = "SavedItem.savedItemId, " + + "SavedItem.slug, " + + "SavedItem.createdAt, " + + + "SavedItem.publisherURLString, " + + "SavedItem.title, " + + "SavedItem.author, " + + "SavedItem.imageURLString, " + + "SavedItem.isArchived, " + + "SavedItem.pageURLString, " + + "SavedItem.contentReader, " + + "SavedItem.savedAt, " + + "SavedItem.readingProgress, " + + "SavedItem.readingProgressAnchor, " + + "SavedItem.serverSyncStatus, " + + + "SavedItem.wordsCount, " + + "SavedItemLabel.savedItemLabelId, " + + "SavedItemLabel.name, " + + "SavedItemLabel.color, " + + "Highlight.highlightId, " + + "Highlight.shortId, " + + "Highlight.createdByMe " } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt index bc3c1056a..87612a4b3 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/auth/EmailLogin.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp @@ -113,7 +114,10 @@ fun LoginFields( placeholder = { Text(text = "user@email.com") }, label = { Text(text = "Email") }, onValueChange = onEmailChange, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Email, + ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) @@ -123,7 +127,10 @@ fun LoginFields( label = { Text(text = "Password") }, onValueChange = onPasswordChange, visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + keyboardType = KeyboardType.Password, + ), keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }) ) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/components/LabelsSelectionSheet.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/components/LabelsSelectionSheet.kt index cf0ab9623..f441e0172 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/components/LabelsSelectionSheet.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/components/LabelsSelectionSheet.kt @@ -76,7 +76,7 @@ fun LabelsSelectionSheet(viewModel: LibraryViewModel) { isLibraryMode = false, onSave = { if (it != labels) { - viewModel.updateSavedItemLabels(savedItemID = currentSavedItemData.cardData.savedItemId, labels = it) + viewModel.updateSavedItemLabels(savedItemID = currentSavedItemData.savedItem.savedItemId, labels = it) } viewModel.labelsSelectionCurrentItemLiveData.value = null viewModel.showLabelsSelectionSheetLiveData.value = false diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryFilterBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryFilterBar.kt index 1e3dad14a..f7a6c5660 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryFilterBar.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryFilterBar.kt @@ -1,18 +1,24 @@ package app.omnivore.omnivore.ui.library +import androidx.compose.ui.graphics.Color import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import app.omnivore.omnivore.R import app.omnivore.omnivore.persistence.entities.SavedItemLabel import app.omnivore.omnivore.ui.components.LabelChipColors diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryNavigationBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryNavigationBar.kt new file mode 100644 index 000000000..afd43845b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryNavigationBar.kt @@ -0,0 +1,267 @@ +package app.omnivore.omnivore.ui.library + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavHostController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.persistence.entities.SavedItemCardData +import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels +import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LibraryNavigationBar( + savedItemViewModel: SavedItemViewModel, + onSearchClicked: () -> Unit, + onSettingsIconClick: () -> Unit +) { + val actionsMenuItem: SavedItemWithLabelsAndHighlights? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(null) + + TopAppBar( + title = { + Text(if (actionsMenuItem == null) "Library" else "") + }, + modifier = Modifier.statusBarsPadding(), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = if (actionsMenuItem == null) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.surfaceVariant + ), + navigationIcon = { + if (actionsMenuItem != null) { + IconButton(onClick = { + savedItemViewModel.actionsMenuItemLiveData.postValue(null) + }) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack, + modifier = Modifier, + contentDescription = "Back" + ) + } + } + }, + actions = { + actionsMenuItem?.let { + IconButton(onClick = { + savedItemViewModel.handleSavedItemAction( + it.savedItem.savedItemId, + if (it.savedItem.isArchived) SavedItemAction.Unarchive else SavedItemAction.Archive + ) }) { + if (it.savedItem.isArchived) { + Icon( + painter = painterResource(id = R.drawable.unarchive), + contentDescription = null + ) + } else { + Icon( + painter = painterResource(id = R.drawable.archive_outline), + contentDescription = null + ) + } + } + IconButton(onClick = { savedItemViewModel.handleSavedItemAction(it.savedItem.savedItemId, SavedItemAction.EditLabels) }) { + Icon( + painter = painterResource(id = R.drawable.tag), + contentDescription = null + ) + } + IconButton(onClick = { savedItemViewModel.handleSavedItemAction(it.savedItem.savedItemId, SavedItemAction.Delete) }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null + ) + } +// IconButton(onClick = onSettingsIconClick) { +// Icon( +// imageVector = Icons.Default.MoreVert, +// contentDescription = null +// ) +// } + } ?: run { + IconButton(onClick = onSearchClicked) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null + ) + } + + IconButton(onClick = onSettingsIconClick) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null + ) + } + } + } + ) +} +// +//@OptIn(ExperimentalMaterial3Api::class) +//@Composable +//fun LibraryBottomBar( +// savedItemViewModel: SavedItemViewModel, +// onSearchClicked: () -> Unit, +// onSettingsIconClick: () -> Unit +//) { +// val actionsMenuItem: SavedItemCardData? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(null) +// +// BottomAppBar( +//// colors = TopAppBarDefaults.topAppBarColors( +//// containerColor = if (actionsMenuItem == null) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.surfaceVariant +//// ), +//// trailingIcon = { +//// if (actionsMenuItem != null) { +//// IconButton(onClick = { +//// savedItemViewModel.actionsMenuItemLiveData.postValue(null) +//// }) { +//// Icon( +//// imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack, +//// modifier = Modifier, +//// contentDescription = "Back" +//// ) +//// } +//// } +//// }, +// floatingActionButton = { +// IconButton(onClick = onSearchClicked) { +// Icon( +// imageVector = Icons.Outlined.Close, +// contentDescription = null +// ) +// } +// }, +// actions = { +// actionsMenuItem?.let { +// IconButton(onClick = onSearchClicked) { +// if (it.isArchived) { +// Icon( +// painter = painterResource(id = R.drawable.unarchive), +// contentDescription = null +// ) +// } else { +// Icon( +// painter = painterResource(id = R.drawable.archive_outline), +// contentDescription = null +// ) +// } +// } +// IconButton(onClick = onSearchClicked) { +// Icon( +// painter = painterResource(id = R.drawable.tag), +// contentDescription = null +// ) +// } +// IconButton(onClick = onSearchClicked) { +// Icon( +// imageVector = Icons.Outlined.Delete, +// contentDescription = null +// ) +// } +// IconButton(onClick = onSettingsIconClick) { +// Icon( +// imageVector = Icons.Default.MoreVert, +// contentDescription = null +// ) +// } +// } ?: run { +// IconButton(onClick = onSearchClicked) { +// Icon( +// imageVector = Icons.Filled.Search, +// contentDescription = null +// ) +// } +// +// IconButton(onClick = onSettingsIconClick) { +// Icon( +// imageVector = Icons.Default.MoreVert, +// contentDescription = null +// ) +// } +// } +// } +// ) +//} + + + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) +@Composable +fun SearchField( + searchText: String, + onSearch: () -> Unit, + onSearchTextChanged: (String) -> Unit, + navController: NavHostController, +) { + var showClearButton by remember { mutableStateOf(false) } + val focusRequester = remember { FocusRequester() } + + TextField( + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { focusState -> + showClearButton = (focusState.isFocused) + } + .focusRequester(focusRequester), + value = searchText, + onValueChange = onSearchTextChanged, + placeholder = { + Text(text = "Search") + }, + leadingIcon = { + IconButton( + onClick = { + onSearchTextChanged("") + navController.popBackStack() + }) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack, + contentDescription = "Back" + ) + } + }, + trailingIcon = { + AnimatedVisibility( + visible = showClearButton, + enter = fadeIn(), + exit = fadeOut() + ) { + IconButton(onClick = { onSearchTextChanged("") }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null + ) + } + + } + }, + maxLines = 1, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + onSearch() + }), + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryView.kt index 70804fc19..3d9596578 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryView.kt @@ -2,30 +2,45 @@ package app.omnivore.omnivore.ui.library import android.content.Intent import android.util.Log +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController +import app.omnivore.omnivore.R import app.omnivore.omnivore.Routes +import app.omnivore.omnivore.persistence.entities.SavedItemCardData import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels +import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights import app.omnivore.omnivore.ui.components.LabelsSelectionSheet import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard import app.omnivore.omnivore.ui.reader.PDFReaderActivity import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity +import app.omnivore.omnivore.ui.save.SaveSheetActivityBase import kotlinx.coroutines.flow.distinctUntilChanged @@ -37,24 +52,25 @@ fun LibraryView( ) { Scaffold( topBar = { - SearchBar( - libraryViewModel = libraryViewModel, + LibraryNavigationBar( + savedItemViewModel = libraryViewModel, + onSearchClicked = { navController.navigate(Routes.Search.route) }, onSettingsIconClick = { navController.navigate(Routes.Settings.route) } ) - } + + }, ) { paddingValues -> - LibraryViewContent( - libraryViewModel, - modifier = Modifier - .padding( - top = paddingValues.calculateTopPadding(), - bottom = paddingValues.calculateBottomPadding() - ) - ) + LibraryViewContent( + libraryViewModel, + modifier = Modifier + .padding( + top = paddingValues.calculateTopPadding() + ) + ) } } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) { val context = LocalContext.current @@ -65,8 +81,9 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) { onRefresh = { libraryViewModel.refresh() } ) - val cardsData: List by libraryViewModel.itemsLiveData.observeAsState(listOf()) - val searchedCardsData: List by libraryViewModel.searchItemsLiveData.observeAsState(listOf()) + val cardsData: List by libraryViewModel.itemsLiveData.observeAsState( + listOf() + ) Box( modifier = Modifier @@ -82,22 +99,26 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) { .fillMaxSize() .padding(horizontal = 6.dp) ) { - if (!libraryViewModel.showSearchField) { - item { - LibraryFilterBar(libraryViewModel) - } + item { + LibraryFilterBar(libraryViewModel) } - items(if (libraryViewModel.showSearchField) searchedCardsData else cardsData) { cardDataWithLabels -> + items(cardsData) { cardDataWithLabels -> SavedItemCard( - cardData = cardDataWithLabels.cardData, - labels = cardDataWithLabels.labels, + savedItemViewModel = libraryViewModel, + savedItem = cardDataWithLabels, onClickHandler = { - val activityClass = if (cardDataWithLabels.cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java + val activityClass = + if (cardDataWithLabels.savedItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java val intent = Intent(context, activityClass) - intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.cardData.slug) + intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.savedItem.slug) context.startActivity(intent) }, - actionHandler = { libraryViewModel.handleSavedItemAction(cardDataWithLabels.cardData.savedItemId, it) } + actionHandler = { + libraryViewModel.handleSavedItemAction( + cardDataWithLabels.savedItem.savedItemId, + it + ) + } ) } } @@ -111,7 +132,7 @@ fun LibraryViewContent(libraryViewModel: LibraryViewModel, modifier: Modifier) { libraryViewModel.loadUsingSearchAPI() } } - + PullRefreshIndicator( refreshing = libraryViewModel.isRefreshing, state = pullRefreshState, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt index 012615283..cc4bb85ed 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/LibraryViewModel.kt @@ -4,16 +4,14 @@ import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.MediatorLiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import app.omnivore.omnivore.* import app.omnivore.omnivore.dataService.* import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput import app.omnivore.omnivore.networking.* import app.omnivore.omnivore.persistence.entities.* +import com.apollographql.apollo3.api.Optional import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* import kotlinx.coroutines.channels.Channel @@ -25,7 +23,7 @@ class LibraryViewModel @Inject constructor( private val networker: Networker, private val dataService: DataService, private val datastoreRepo: DatastoreRepository -): ViewModel() { +): ViewModel(), SavedItemViewModel { private val contentRequestChannel = Channel(capacity = Channel.UNLIMITED) private var cursor: String? = null @@ -37,10 +35,14 @@ class LibraryViewModel @Inject constructor( private var receivedIdx = 0 // Live Data - val searchTextLiveData = MutableLiveData("") - val searchItemsLiveData = MutableLiveData>(listOf()) - private var itemsLiveDataInternal = dataService.libraryLiveData(SavedItemFilter.INBOX, SavedItemSortFilter.NEWEST, listOf()) - val itemsLiveData = MediatorLiveData>() + private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData( + archiveFilter = 1, + sortKey = "newest", + requiredLabels = listOf(), + excludedLabels = listOf(), + allowedContentReaders = listOf("WEB", "PDF", "EPUB") + ) + val itemsLiveData = MediatorLiveData>() val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX) val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST) val showLabelsSelectionSheetLiveData = MutableLiveData(false) @@ -48,8 +50,9 @@ class LibraryViewModel @Inject constructor( val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData() val activeLabelsLiveData = MutableLiveData>(listOf()) + override val actionsMenuItemLiveData = MutableLiveData(null) + var isRefreshing by mutableStateOf(false) - var showSearchField by mutableStateOf(false) var hasLoadedInitialFilters = false fun loadInitialFilterValues() { @@ -62,25 +65,27 @@ class LibraryViewModel @Inject constructor( } } - runBlocking { - datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemFilter)?.let { str -> - try { - val filter = SavedItemFilter.values().first { it.rawValue == str } - appliedFilterLiveData.postValue(filter) - } catch (e: Exception) { - Log.d("error", "invalid filter value stored in datastore repo: $e") - } - } - - datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemSortFilter)?.let { str -> - try { - val filter = SavedItemSortFilter.values().first { it.rawValue == str } - appliedSortFilterLiveData.postValue(filter) - } catch (e: Exception) { - Log.d("error", "invalid sort filter value stored in datastore repo: $e") - } - } - } +// runBlocking { +// datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemFilter)?.let { str -> +// try { +// val filter = SavedItemFilter.values().first { it.rawValue == str } +// appliedFilterLiveData.postValue(filter) +// } catch (e: Exception) { +// Log.d("error", "invalid filter value stored in datastore repo: $e") +// } +// +// datastoreRepo.getString(DatastoreKeys.lastUsedSavedItemSortFilter)?.let { str -> +// try { +// val filter = SavedItemSortFilter.values().first { it.rawValue == str } +// appliedSortFilterLiveData.postValue(filter) +// } catch (e: Exception) { +// Log.d("error", "invalid sort filter value stored in datastore repo: $e") +// } +// +// handleFilterChanges() +// } +// } +// } viewModelScope.launch { handleFilterChanges() @@ -92,16 +97,6 @@ class LibraryViewModel @Inject constructor( } } - fun updateSearchText(text: String) { - searchTextLiveData.value = text - - if (text == "") { - searchItemsLiveData.value = listOf() - } else { - load(clearPreviousSearch = true) - } - } - fun refresh() { isRefreshing = true load(true) @@ -134,12 +129,8 @@ class LibraryViewModel @Inject constructor( loadInitialFilterValues() viewModelScope.launch { - if (searchTextLiveData.value != "") { - performSearch(clearPreviousSearch) - } else { - syncItems() - loadUsingSearchAPI() - } + syncItems() + loadUsingSearchAPI() } } @@ -154,12 +145,12 @@ class LibraryViewModel @Inject constructor( isRefreshing = false } - result.savedItemSlugs.map { - val isSavedInDB = dataService.isSavedItemContentStoredInDB(it) + result.savedItems.map { + val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug) if (!isSavedInDB) { delay(2000) - contentRequestChannel.send(it) + contentRequestChannel.send(it.savedItem.slug) } } } @@ -189,12 +180,56 @@ class LibraryViewModel @Inject constructor( } } - suspend fun handleFilterChanges() { - if (searchTextLiveData.value != "") { - performSearch(true) - } else if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) { - itemsLiveDataInternal = dataService.libraryLiveData(appliedFilterLiveData.value!!, appliedSortFilterLiveData.value!!, activeLabelsLiveData.value ?: listOf()) - itemsLiveData.removeSource(itemsLiveDataInternal) + fun sortKey(appliedSortKey: String) { + when(appliedSortKey) { + + } + } + fun handleFilterChanges() { + if (appliedSortFilterLiveData.value != null && appliedFilterLiveData.value != null) { + val applied = appliedFilterLiveData.value + val sortKey = when (appliedSortFilterLiveData.value) { + SavedItemSortFilter.NEWEST -> "newest" + SavedItemSortFilter.OLDEST -> "oldest" + SavedItemSortFilter.RECENTLY_READ -> "recentlyRead" + SavedItemSortFilter.RECENTLY_PUBLISHED -> "recentlyPublished" + else -> "newest" + } + + val archiveFilter = when (appliedFilterLiveData.value) { + SavedItemFilter.ARCHIVED -> 0 + else -> 1 + } + + val allowedContentReaders = when(appliedFilterLiveData.value) { + SavedItemFilter.FILES -> listOf("PDF", "EPUB") + else -> listOf("WEB", "PDF", "EPUB") + } + + var requiredLabels = when(appliedFilterLiveData.value) { + SavedItemFilter.NEWSLETTERS -> listOf("Newsletter") + else -> (activeLabelsLiveData.value ?: listOf()).map { it.name } + } + activeLabelsLiveData.value?.let { + requiredLabels = requiredLabels + it.map { it.name } + } + + + val excludeLabels = when(appliedFilterLiveData.value) { + SavedItemFilter.READ_LATER -> listOf("Newsletter") + else -> listOf() + } + + val newData = dataService.db.savedItemDao().filteredLibraryData( + archiveFilter = archiveFilter, + sortKey = sortKey, + requiredLabels = requiredLabels, + excludedLabels = excludeLabels, + allowedContentReaders = allowedContentReaders + ) + + itemsLiveData.removeSource(itemsLiveDataInternal) + itemsLiveDataInternal = newData itemsLiveData.addSource(itemsLiveDataInternal, itemsLiveData::setValue) } } @@ -238,38 +273,7 @@ class LibraryViewModel @Inject constructor( } } - private suspend fun performSearch(clearPreviousSearch: Boolean) { - if (clearPreviousSearch) { - cursor = null - } - - val thisSearchIdx = searchIdx - searchIdx += 1 - - // Execute the search - val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "") - - // Search results aren't guaranteed to return in order so this - // will discard old results that are returned while a user is typing. - // For example if a user types 'Canucks', often the search results - // for 'C' are returned after 'Canucks' because it takes the backend - // much longer to compute. - if (thisSearchIdx in 1..receivedIdx) { - return - } - - val cardsDataWithLabels = searchResult.cardsData.map { - SavedItemCardDataWithLabels(cardData = it, labels = listOf()) - } - - searchItemsLiveData.postValue(cardsDataWithLabels) - - CoroutineScope(Dispatchers.Main).launch { - isRefreshing = false - } - } - - fun handleSavedItemAction(itemID: String, action: SavedItemAction) { + override fun handleSavedItemAction(itemID: String, action: SavedItemAction) { when (action) { SavedItemAction.Delete -> { viewModelScope.launch { @@ -291,6 +295,7 @@ class LibraryViewModel @Inject constructor( showLabelsSelectionSheetLiveData.value = true } } + actionsMenuItemLiveData.postValue(null) } fun updateSavedItemLabels(savedItemID: String, labels: List) { @@ -323,7 +328,7 @@ class LibraryViewModel @Inject constructor( fun createNewSavedItemLabel(labelName: String, hexColorValue: String) { viewModelScope.launch { withContext(Dispatchers.IO) { - val newLabel = networker.createNewLabel(CreateLabelInput(color = hexColorValue, name = labelName)) + val newLabel = networker.createNewLabel(CreateLabelInput(color = Optional.presentIfNotNull(hexColorValue), name = labelName)) newLabel?.let { val savedItemLabel = SavedItemLabel( @@ -340,9 +345,9 @@ class LibraryViewModel @Inject constructor( } } - fun currentSavedItemUnderEdit(): SavedItemCardDataWithLabels? { + fun currentSavedItemUnderEdit(): SavedItemWithLabelsAndHighlights? { labelsSelectionCurrentItemLiveData.value?.let { itemID -> - return itemsLiveData.value?.first { it.cardData.savedItemId == itemID } + return itemsLiveData.value?.first { it.savedItem.savedItemId == itemID } } return null @@ -350,11 +355,6 @@ class LibraryViewModel @Inject constructor( private fun searchQueryString(): String { var query = "${appliedFilterLiveData.value?.queryString} ${appliedSortFilterLiveData.value?.queryString}" - val searchText = searchTextLiveData.value ?: "" - - if (searchText.isNotEmpty()) { - query += " $searchText" - } activeLabelsLiveData.value?.let { if (it.isNotEmpty()) { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SavedItemViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SavedItemViewModel.kt new file mode 100644 index 000000000..1d2728d8a --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SavedItemViewModel.kt @@ -0,0 +1,13 @@ +package app.omnivore.omnivore.ui.library + +import androidx.lifecycle.MutableLiveData +import app.omnivore.omnivore.persistence.entities.SavedItemCardData +import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights + +interface SavedItemViewModel { + + val actionsMenuItemLiveData: MutableLiveData + get() = MutableLiveData(null) + + fun handleSavedItemAction(itemID: String, action: SavedItemAction) +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchBar.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchBar.kt deleted file mode 100644 index ca7ff4ff2..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchBar.kt +++ /dev/null @@ -1,132 +0,0 @@ -package app.omnivore.omnivore.ui.library - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import app.omnivore.omnivore.R - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SearchBar( - libraryViewModel: LibraryViewModel, - onSettingsIconClick: () -> Unit -) { - val searchText: String by libraryViewModel.searchTextLiveData.observeAsState("") - - TopAppBar( - title = { - if (libraryViewModel.showSearchField) { - SearchField(searchText) { libraryViewModel.updateSearchText(it) } - } else { - Text("Library") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ), - actions = { - if (libraryViewModel.showSearchField) { - Text( - text = "Cancel", - modifier = Modifier - .clickable { - libraryViewModel.updateSearchText("") - libraryViewModel.showSearchField = false - } - .padding(horizontal = 6.dp) - ) - } else { - IconButton(onClick = { libraryViewModel.showSearchField = true }) { - Icon( - imageVector = Icons.Filled.Search, - contentDescription = null - ) - } - - IconButton(onClick = onSettingsIconClick) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = null - ) - } - } - } - ) -} - - -@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) -@Composable -fun SearchField( - searchText: String, - onSearchTextChanged: (String) -> Unit -) { - var showClearButton by remember { mutableStateOf(false) } - val keyboardController = LocalSoftwareKeyboardController.current - val focusRequester = remember { FocusRequester() } - - Row { - TextField( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - .onFocusChanged { focusState -> - showClearButton = (focusState.isFocused) - } - .focusRequester(focusRequester), - value = searchText, - onValueChange = onSearchTextChanged, - placeholder = { - Text(text = "Search") - }, - trailingIcon = { - AnimatedVisibility( - visible = showClearButton, - enter = fadeIn(), - exit = fadeOut() - ) { - IconButton(onClick = { onSearchTextChanged("") }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = null - ) - } - - } - }, - maxLines = 1, - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { - keyboardController?.hide() - }), - ) - } - - - LaunchedEffect(Unit) { - focusRequester.requestFocus() - } -} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchView.kt new file mode 100644 index 000000000..f2aaa71d6 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchView.kt @@ -0,0 +1,203 @@ +package app.omnivore.omnivore.ui.library + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.navigation.NavHostController +import app.omnivore.omnivore.R +import app.omnivore.omnivore.persistence.entities.SavedItemCardData +import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels +import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights +import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainerActivity +import app.omnivore.omnivore.persistence.entities.TypeaheadCardData +import app.omnivore.omnivore.ui.reader.PDFReaderActivity +import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard +import app.omnivore.omnivore.ui.savedItemViews.TypeaheadSearchCard + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchView( + viewModel: SearchViewModel, + navController: NavHostController +) { + val isRefreshing: Boolean by viewModel.isRefreshing.observeAsState(false) + val typeaheadMode: Boolean by viewModel.typeaheadMode.observeAsState(true) + val searchText: String by viewModel.searchTextLiveData.observeAsState("") + val actionsMenuItem: SavedItemWithLabelsAndHighlights? by viewModel.actionsMenuItemLiveData.observeAsState(null) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + navigationIcon = { + if (actionsMenuItem != null) { + IconButton(onClick = { + viewModel.actionsMenuItemLiveData.postValue(null) + }) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack, + modifier = Modifier, + contentDescription = "Back" + ) + } + } + }, + actions = { + if (actionsMenuItem != null) { + IconButton(onClick = { }) { + Icon( + painter = painterResource(id = R.drawable.archive_outline), + contentDescription = null + ) + } + IconButton(onClick = { }) { + Icon( + painter = painterResource(id = R.drawable.tag), + contentDescription = null + ) + } + IconButton(onClick = { }) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = null + ) + } + IconButton(onClick = { }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = null + ) + } + } else { + Row { + SearchField( + searchText, + onSearch = { + viewModel.performSearch() + }, + onSearchTextChanged = { viewModel.updateSearchText(it) }, + navController = navController + ) + } + } + } + ) + + } + ) { paddingValues -> + if (isRefreshing) { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier + .padding(top = paddingValues.calculateTopPadding()) + .fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier + .height(45.dp) + .width(45.dp), + strokeWidth = 5.dp, + color = colorResource(R.color.green_55B938) + ) + } + } else if (typeaheadMode) { + TypeaheadSearchViewContent( + viewModel, + modifier = Modifier + .padding(top = paddingValues.calculateTopPadding()) + ) + } else { + SearchViewContent( + viewModel, + modifier = Modifier + .padding(top = paddingValues.calculateTopPadding()) + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun TypeaheadSearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { + val context = LocalContext.current + val listState = rememberLazyListState() + + val searchedCardsData: List by viewModel.searchItemsLiveData.observeAsState(listOf()) + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + + ) { + items(searchedCardsData) { cardData -> + TypeaheadSearchCard( + cardData = cardData, + onClickHandler = { + // val activityClass = if (cardData.isPDF()) PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java + val activityClass = WebReaderLoadingContainerActivity::class.java + val intent = Intent(context, activityClass) + intent.putExtra("SAVED_ITEM_SLUG", cardData.slug) + context.startActivity(intent) + }, + actionHandler = { viewModel.handleSavedItemAction(cardData.savedItemId, it) } + ) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SearchViewContent(viewModel: SearchViewModel, modifier: Modifier) { + val context = LocalContext.current + val listState = rememberLazyListState() + + val cardsData: List by viewModel.itemsLiveData.observeAsState(listOf()) + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize() + + ) { + items(cardsData) { cardDataWithLabels -> + SavedItemCard( + savedItemViewModel = viewModel, + savedItem = cardDataWithLabels, + onClickHandler = { + val activityClass = if (cardDataWithLabels.savedItem.contentReader == "PDF") PDFReaderActivity::class.java else WebReaderLoadingContainerActivity::class.java + val intent = Intent(context, activityClass) + intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.savedItem.slug) + context.startActivity(intent) + }, + actionHandler = { viewModel.handleSavedItemAction(cardDataWithLabels.savedItem.savedItemId, it) } + ) + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt new file mode 100644 index 000000000..8db931e1a --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/library/SearchViewModel.kt @@ -0,0 +1,211 @@ +package app.omnivore.omnivore.ui.library + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.omnivore.omnivore.* +import app.omnivore.omnivore.dataService.* +import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput +import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput +import app.omnivore.omnivore.networking.* +import app.omnivore.omnivore.persistence.entities.* +import com.apollographql.apollo3.api.Optional +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.time.Instant +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val networker: Networker, + private val dataService: DataService, + private val datastoreRepo: DatastoreRepository +): ViewModel(), SavedItemViewModel { + private val contentRequestChannel = Channel(capacity = Channel.UNLIMITED) + + private var cursor: String? = null + private var librarySearchCursor: String? = null + + // These are used to make sure we handle search result + // responses in the right order + private var searchIdx = 0 + private var receivedIdx = 0 + + // Live Data + var isRefreshing = MutableLiveData(false) + val typeaheadMode = MutableLiveData(true) + val searchTextLiveData = MutableLiveData("") + val searchItemsLiveData = MutableLiveData>(listOf()) + val itemsLiveData = MediatorLiveData>() + + override val actionsMenuItemLiveData = MutableLiveData(null) + + fun updateSearchText(text: String) { + typeaheadMode.postValue(true) + searchTextLiveData.value = text + + if (text == "") { + searchItemsLiveData.value = listOf() + } else { + viewModelScope.launch { + performTypeaheadSearch(true) + } + } + } + + fun performSearch() { + // To perform search we just clear the current state, so the LibraryView infinite scroll + // load will update items. + viewModelScope.launch { + isRefreshing.postValue(true) + itemsLiveData.postValue(listOf()) + typeaheadMode.postValue(false) + + loadUsingSearchAPI() + } + } + + fun loadUsingSearchAPI() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + val result = dataService.librarySearch(cursor = librarySearchCursor, query = searchQueryString()) + result.cursor?.let { + librarySearchCursor = it + } + + result.savedItems.map { + val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug) + + if (!isSavedInDB) { + delay(2000) + contentRequestChannel.send(it.savedItem.slug) + } + } + + val newItems = result.savedItems + /* + .map { + SavedItemCardDataWithLabels( + cardData = SavedItemCardData( + savedItemId = it.savedItem.savedItemId, + slug = it.savedItem.slug, + publisherURLString = it.savedItem.publisherURLString, + title = it.savedItem.title, + author = it.savedItem.author, + imageURLString = it.savedItem.imageURLString, + isArchived = it.savedItem.isArchived, + pageURLString = it.savedItem.pageURLString, + contentReader = it.savedItem.contentReader, + savedAt = it.savedItem.savedAt, + readingProgress = it.savedItem.readingProgress, + wordsCount = it.savedItem.wordsCount + ), + labels = listOf() + ) + } + */ + + itemsLiveData.value?.let{ + itemsLiveData.postValue(newItems + it) + } ?: run { + itemsLiveData.postValue(newItems) + } + + isRefreshing.postValue(false) + } + } + } + + private suspend fun performTypeaheadSearch(clearPreviousSearch: Boolean) { + if (clearPreviousSearch) { + cursor = null + } + + val thisSearchIdx = searchIdx + searchIdx += 1 + + // Execute the search + val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "") + + // Search results aren't guaranteed to return in order so this + // will discard old results that are returned while a user is typing. + // For example if a user types 'Canucks', often the search results + // for 'C' are returned after 'Canucks' because it takes the backend + // much longer to compute. + if (thisSearchIdx in 1..receivedIdx) { + return + } + + searchItemsLiveData.postValue(searchResult.cardsData) + + isRefreshing.postValue(false) + } + + override fun handleSavedItemAction(itemID: String, action: SavedItemAction) { + when (action) { + SavedItemAction.Delete -> { + viewModelScope.launch { + dataService.deleteSavedItem(itemID) + } + } + SavedItemAction.Archive -> { + viewModelScope.launch { + dataService.archiveSavedItem(itemID) + } + } + SavedItemAction.Unarchive -> { + viewModelScope.launch { + dataService.unarchiveSavedItem(itemID) + } + } + SavedItemAction.EditLabels -> { + // TODO + } + } + actionsMenuItemLiveData.postValue(null) + } + + fun updateSavedItemLabels(savedItemID: String, labels: List) { +// viewModelScope.launch { +// withContext(Dispatchers.IO) { +// val input = SetLabelsInput(labelIds = labels.map { it.savedItemLabelId }, pageId = savedItemID) +// val networkResult = networker.updateLabelsForSavedItem(input) +// +// // TODO: assign a server sync status to these +// val crossRefs = labels.map { +// SavedItemAndSavedItemLabelCrossRef( +// savedItemLabelId = it.savedItemLabelId, +// savedItemId = savedItemID +// ) +// } +// +// // Remove all labels first +// dataService.db.savedItemAndSavedItemLabelCrossRefDao().deleteRefsBySavedItemId(savedItemID) +// +// // Add back the current labels +// dataService.db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs) +// +// CoroutineScope(Dispatchers.Main).launch { +// handleFilterChanges() +// } +// } +// } + } + + private fun searchQueryString(): String { + var query = "" + val searchText = searchTextLiveData.value ?: "" + + if (searchText.isNotEmpty()) { + query += " $searchText" + } + + return query + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt index d6249cd21..3cbd729d2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReader.kt @@ -134,13 +134,16 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionMan private fun loadHighlights(highlights: List) { for (highlight in highlights) { - val highlightAnnotation = fragment - .document - ?.annotationProvider - ?.createAnnotationFromInstantJson(highlight.patch) + val patch = highlight.patch + if (patch != null) { + val highlightAnnotation = fragment + .document + ?.annotationProvider + ?.createAnnotationFromInstantJson(patch) - highlightAnnotation?.let { - fragment.addAnnotationToPage(highlightAnnotation, true) + highlightAnnotation?.let { + fragment.addAnnotationToPage(highlightAnnotation, true) + } } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt index f5d37fc62..d5ec05cd7 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/PDFReaderViewModel.kt @@ -133,8 +133,8 @@ class PDFReaderViewModel @Inject constructor( annotation = Optional.presentIfNotNull(note), articleId = itemID, id = highlightID, - patch = newAnnotation.toInstantJson(), - quote = quote, + patch = Optional.presentIfNotNull(newAnnotation.toInstantJson()), + quote = Optional.presentIfNotNull(quote), shortId = shortID, ) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt index 2fd99ba6e..53fa6c433 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderLoadingContainer.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* import androidx.compose.material.TopAppBar import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Settings @@ -32,6 +33,8 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavHostController +import androidx.navigation.compose.rememberNavController import app.omnivore.omnivore.MainActivity import app.omnivore.omnivore.R import app.omnivore.omnivore.ui.components.WebReaderLabelsSelectionSheet @@ -40,6 +43,7 @@ import app.omnivore.omnivore.ui.theme.OmnivoreTheme import com.google.accompanist.systemuicontroller.rememberSystemUiController import dagger.hilt.android.AndroidEntryPoint import kotlin.math.roundToInt +import androidx.navigation.compose.rememberNavController @AndroidEntryPoint @@ -51,6 +55,7 @@ class WebReaderLoadingContainerActivity: ComponentActivity() { val requestID = intent.getStringExtra("SAVED_ITEM_REQUEST_ID") val slug = intent.getStringExtra("SAVED_ITEM_SLUG") + setContent { val systemUiController = rememberSystemUiController() val useDarkIcons = !isSystemInDarkTheme() @@ -77,7 +82,7 @@ class WebReaderLoadingContainerActivity: ComponentActivity() { requestID = requestID, slug = slug, onLibraryIconTap = if (requestID != null) { { startMainActivity() } } else null, - webReaderViewModel = viewModel + webReaderViewModel = viewModel, ) } } @@ -137,6 +142,17 @@ fun WebReaderLoadingContainer(slug: String? = null, requestID: String? = null, o }), backgroundColor = MaterialTheme.colorScheme.surfaceVariant, title = {}, + navigationIcon = { + IconButton(onClick = { + onBackPressedDispatcher?.onBackPressed() + }) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Filled.ArrowBack, + modifier = Modifier, + contentDescription = "Back" + ) + } + }, actions = { if (onLibraryIconTap != null) { IconButton(onClick = { onLibraryIconTap() }) { diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index ad442fbf9..38d037106 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -19,6 +19,8 @@ import app.omnivore.omnivore.networking.* import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef import app.omnivore.omnivore.persistence.entities.SavedItemLabel import app.omnivore.omnivore.ui.library.SavedItemAction +import com.apollographql.apollo3.api.Optional +import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.* @@ -394,7 +396,8 @@ class WebReaderViewModel @Inject constructor( fun createNewSavedItemLabel(labelName: String, hexColorValue: String) { viewModelScope.launch { withContext(Dispatchers.IO) { - val newLabel = networker.createNewLabel(CreateLabelInput(color = hexColorValue, name = labelName)) + + val newLabel = networker.createNewLabel(CreateLabelInput(color = Optional.presentIfNotNull(hexColorValue), name = labelName)) newLabel?.let { val savedItemLabel = SavedItemLabel( diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt index 01e5ffb26..c9b7e0c10 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/root/RootView.kt @@ -13,11 +13,15 @@ import androidx.compose.ui.graphics.Color import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import app.omnivore.omnivore.DatastoreRepository import app.omnivore.omnivore.Routes +import app.omnivore.omnivore.dataService.DataService import app.omnivore.omnivore.ui.auth.LoginViewModel import app.omnivore.omnivore.ui.auth.WelcomeScreen import app.omnivore.omnivore.ui.library.LibraryView +import app.omnivore.omnivore.ui.library.SearchView import app.omnivore.omnivore.ui.library.LibraryViewModel +import app.omnivore.omnivore.ui.library.SearchViewModel import app.omnivore.omnivore.ui.settings.PolicyWebView import app.omnivore.omnivore.ui.settings.SettingsViewModel import com.google.accompanist.systemuicontroller.rememberSystemUiController @@ -25,6 +29,7 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController @Composable fun RootView( loginViewModel: LoginViewModel, + searchViewModel: SearchViewModel, libraryViewModel: LibraryViewModel, settingsViewModel: SettingsViewModel ) { @@ -48,6 +53,7 @@ fun RootView( if (hasAuthToken) { PrimaryNavigator( loginViewModel = loginViewModel, + searchViewModel = searchViewModel, libraryViewModel = libraryViewModel, settingsViewModel = settingsViewModel ) @@ -68,6 +74,7 @@ fun RootView( fun PrimaryNavigator( loginViewModel: LoginViewModel, libraryViewModel: LibraryViewModel, + searchViewModel: SearchViewModel, settingsViewModel: SettingsViewModel ) { val navController = rememberNavController() @@ -80,6 +87,13 @@ fun PrimaryNavigator( ) } + composable(Routes.Search.route) { + SearchView( + viewModel = searchViewModel, + navController = navController + ) + } + composable(Routes.Settings.route) { SettingsView(loginViewModel = loginViewModel, settingsViewModel = settingsViewModel, navController = navController) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemCard.kt index ee98ec1f2..2cfc42db2 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemCard.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/SavedItemCard.kt @@ -9,82 +9,92 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.material3.SuggestionChipDefaults.elevatedSuggestionChipColors import androidx.compose.runtime.* +import androidx.compose.runtime.livedata.observeAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.* +import app.omnivore.omnivore.R import app.omnivore.omnivore.persistence.entities.SavedItemCardData import app.omnivore.omnivore.persistence.entities.SavedItemLabel +import app.omnivore.omnivore.persistence.entities.SavedItemWithLabelsAndHighlights import app.omnivore.omnivore.ui.components.LabelChipColors +import app.omnivore.omnivore.ui.library.LibraryViewModel import app.omnivore.omnivore.ui.library.SavedItemAction +import app.omnivore.omnivore.ui.library.SavedItemViewModel import coil.compose.rememberAsyncImagePainter -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, +) @Composable -fun SavedItemCard(cardData: SavedItemCardData, labels: List, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) { - var isMenuExpanded by remember { mutableStateOf(false) } - val publisherDisplayName = cardData.publisherDisplayName() +fun SavedItemCard(savedItemViewModel: SavedItemViewModel, savedItem: SavedItemWithLabelsAndHighlights, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) { val listState = rememberLazyListState() + val actionsMenuItem: SavedItemWithLabelsAndHighlights? by savedItemViewModel.actionsMenuItemLiveData.observeAsState(null) + Column( modifier = Modifier .combinedClickable( onClick = onClickHandler, - onLongClick = { isMenuExpanded = true } + onLongClick = { savedItemViewModel.actionsMenuItemLiveData.postValue(savedItem) } ) + .fillMaxWidth() ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top, modifier = Modifier .fillMaxWidth() - .padding(12.dp) - .background(if (isMenuExpanded) Color.LightGray else Color.Transparent) + .padding(10.dp) + .background(Color.Transparent) ) { Column( - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), modifier = Modifier .weight(1f, fill = false) - .padding(end = 8.dp) + .padding(end = 20.dp) + .defaultMinSize(minHeight = 55.dp) ) { + readInfo(item = savedItem) + Text( - text = cardData.title, - style = MaterialTheme.typography.titleMedium, + text = savedItem.savedItem.title, + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ), + maxLines = 2, lineHeight = 20.sp ) - if (cardData.author != null && cardData.author != "") { + if (savedItem.savedItem.author != null && savedItem.savedItem.author != "") { Text( - text = "By ${cardData.author}", - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - if (publisherDisplayName != null) { - Text( - text = publisherDisplayName, - style = MaterialTheme.typography.bodyMedium, + text = byline(savedItem), + style = TextStyle( + fontSize = 15.sp, + fontWeight = FontWeight.Normal, + color = Color(red = 137, green = 137, blue = 137) + ), maxLines = 1, overflow = TextOverflow.Ellipsis ) } } - if (cardData.imageURLString != null) { - Image( - painter = rememberAsyncImagePainter(cardData.imageURLString), - contentDescription = "Image associated with saved item", - modifier = Modifier - .padding(top = 6.dp) - .clip(RoundedCornerShape(6.dp)) - .size(80.dp) - ) - } + Image( + painter = rememberAsyncImagePainter(savedItem.savedItem.imageURLString), + contentDescription = "Image associated with saved item", + modifier = Modifier + .size(55.dp, 73.dp) + .clip(RoundedCornerShape(10.dp)) + .defaultMinSize(minWidth = 55.dp, minHeight = 73.dp) + .clip(RoundedCornerShape(10.dp)) + ) } LazyRow( @@ -92,9 +102,9 @@ fun SavedItemCard(cardData: SavedItemCardData, labels: List, onC horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(start = 6.dp) + .padding(start = 10.dp, bottom = 5.dp, end = 10.dp) ) { - items(labels.sortedBy { it.name }) { label -> + items(savedItem.labels.sortedBy { it.name }) { label -> val chipColors = LabelChipColors.fromHex(label.color) SuggestionChip( @@ -106,19 +116,121 @@ fun SavedItemCard(cardData: SavedItemCardData, labels: List, onC labelColor = chipColors.textColor, iconContentColor = chipColors.textColor ), - modifier = Modifier - .padding(horizontal = 4.dp) + modifier = Modifier.padding(end = 5.dp) ) + } } Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) - - SavedItemContextMenu( - isExpanded = isMenuExpanded, - isArchived = cardData.isArchived, - onDismiss = { isMenuExpanded = false }, - actionHandler = actionHandler - ) + } +} + +fun byline(item: SavedItemWithLabelsAndHighlights): String { + item.savedItem.author?.let { + return item.savedItem.author + } + + val publisherDisplayName = item.savedItem.publisherDisplayName() + publisherDisplayName?.let { + return publisherDisplayName + } + + return "" +} + +// +//var readingSpeed: Int64 { +// var result = UserDefaults.standard.integer(forKey: UserDefaultKey.userWordsPerMinute.rawValue) +// if result <= 0 { +// result = 235 +// } +// return Int64(result) +//} + +fun estimatedReadingTime(item: SavedItemWithLabelsAndHighlights): String { + item.savedItem.wordsCount?.let { + if (it > 0) { + val readLen = Math.max(1, it / 235) + return "$readLen MIN READ • " + } + } + return "" +} + +fun readingProgress(item: SavedItemWithLabelsAndHighlights): String { + // If there is no wordsCount don't show progress because it will make no sense + item.savedItem.wordsCount?.let { + if (it > 0) { + val intVal = item.savedItem.readingProgress.toInt() + return "$intVal%" + } + } + return "" +} + +//var highlightsText: String { +// item.hig ?.let { +// if let highlights = item.highlights, highlights.count > 0 { +// let fmted = LocalText.pluralizedText(key: "number_of_highlights", count: highlights.count) +// if item.wordsCount > 0 { +// return " • \(fmted)" +// } +// return fmted +// } +// return "" +//} +// +//var notesText: String { +// let notes = item.highlights?.filter { item in +// if let highlight = item as? Highlight { +// return !(highlight.annotation ?? "").isEmpty +// } +// return false +// } +// +// if let notes = notes, notes.count > 0 { +// let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count) +// if item.wordsCount > 0 { +// return " • \(fmted)" +// } +// return fmted +// } +// return "" +//} + +@Composable +fun readInfo(item: SavedItemWithLabelsAndHighlights) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 15.dp) + ) { + Text( + text = estimatedReadingTime(item), + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = Color(red = 137, green = 137, blue = 137) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = readingProgress(item), + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + color = if (item.savedItem.readingProgress > 1) colorResource(R.color.green_55B938) else colorResource(R.color.gray_898989) + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + +// Text("\(highlightsText)") +// +// Text("\(notesText)") + } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/TypeaheadSearchCard.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/TypeaheadSearchCard.kt new file mode 100644 index 000000000..337b8afae --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/savedItemViews/TypeaheadSearchCard.kt @@ -0,0 +1,67 @@ +package app.omnivore.omnivore.ui.savedItemViews + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.material3.SuggestionChipDefaults.elevatedSuggestionChipColors +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.* +import app.omnivore.omnivore.persistence.entities.SavedItemCardData +import app.omnivore.omnivore.persistence.entities.SavedItemLabel +import app.omnivore.omnivore.persistence.entities.TypeaheadCardData +import app.omnivore.omnivore.ui.components.LabelChipColors +import app.omnivore.omnivore.ui.library.SavedItemAction +import coil.compose.rememberAsyncImagePainter + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class, +) +@Composable +fun TypeaheadSearchCard(cardData: TypeaheadCardData, onClickHandler: () -> Unit, actionHandler: (SavedItemAction) -> Unit) { + Column( + modifier = Modifier + .combinedClickable( + onClick = onClickHandler, + ) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + modifier = Modifier + .fillMaxWidth() + .padding(15.dp) + .background(Color.Transparent) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(5.dp), + modifier = Modifier + .weight(1f, fill = false) + .padding(end = 20.dp) + .defaultMinSize(minHeight = 55.dp) + ) { + Text( + text = cardData.title, + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold + ), + maxLines = 2, + lineHeight = 20.sp + ) + + } + } + + Divider(color = MaterialTheme.colorScheme.outlineVariant, thickness = 1.dp) + } +} diff --git a/android/Omnivore/app/src/main/res/drawable/funnel_simple.xml b/android/Omnivore/app/src/main/res/drawable/funnel_simple.xml new file mode 100644 index 000000000..a85f9ec01 --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/funnel_simple.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/Omnivore/app/src/main/res/drawable/unarchive.xml b/android/Omnivore/app/src/main/res/drawable/unarchive.xml new file mode 100644 index 000000000..922a5617d --- /dev/null +++ b/android/Omnivore/app/src/main/res/drawable/unarchive.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/Omnivore/app/src/main/res/values/colors.xml b/android/Omnivore/app/src/main/res/values/colors.xml index ee4d3d2e0..d81888f7f 100644 --- a/android/Omnivore/app/src/main/res/values/colors.xml +++ b/android/Omnivore/app/src/main/res/values/colors.xml @@ -4,4 +4,10 @@ #FFFFFFFF #F7F7F7 #B7B7B7 + + #898989 + #55B938 + #EBEBEB + + #E6E4BF