Merge pull request #2076 from omnivore-app/fix/android-ui-tweaks

Android tweaks: Set email and password keyboard types on login form, standalone SearchView
This commit is contained in:
Jackson Harper 2023-04-25 10:39:27 +08:00 committed by GitHub
commit fb4ed29e04
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1979 additions and 399 deletions

View file

@ -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 {

View file

@ -40,6 +40,7 @@ fragment ArticleFields on Article {
readAt
updatedAt
content
wordsCount
}
fragment HighlightFields on Highlight {

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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")

View file

@ -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,

View file

@ -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<SavedItemLabel> = mutableListOf()
val crossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = 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<SavedItemWithLabelsAndHighlights>,
val cursor: String?
) {
companion object {
val errorResult = SearchResult(hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItems = listOf())
}
}

View file

@ -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 ?: ""
)
)

View file

@ -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 ?: ""
)
}

View file

@ -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 ?: "")

View file

@ -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<SavedItemCardData>
)
data class LibrarySearchQueryResponse(
val cursor: String?,
val items: List<LibrarySearchItem>
@ -19,39 +14,10 @@ data class LibrarySearchQueryResponse(
data class LibrarySearchItem(
val item: SavedItem,
val labels: List<SavedItemLabel>
val labels: List<SavedItemLabel>,
val highlights: List<Highlight>
)
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,
)
}
)
}

View file

@ -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<TypeaheadCardData>
)
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())
}
}

View file

@ -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

View file

@ -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?,

View file

@ -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<List<SavedItemCardDataWithLabels>>
@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<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>>
fun filteredLibraryData(archiveFilter: Int, sortKey: String, requiredLabels: List<String>, excludedLabels: List<String>, allowedContentReaders: List<String>): LiveData<List<SavedItemWithLabelsAndHighlights>> {
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 "
}

View file

@ -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() })
)

View file

@ -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

View file

@ -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

View file

@ -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()
}
}

View file

@ -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<SavedItemCardDataWithLabels> by libraryViewModel.itemsLiveData.observeAsState(listOf())
val searchedCardsData: List<SavedItemCardDataWithLabels> by libraryViewModel.searchItemsLiveData.observeAsState(listOf())
val cardsData: List<SavedItemWithLabelsAndHighlights> 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,

View file

@ -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<String>(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<List<SavedItemCardDataWithLabels>>(listOf())
private var itemsLiveDataInternal = dataService.libraryLiveData(SavedItemFilter.INBOX, SavedItemSortFilter.NEWEST, listOf())
val itemsLiveData = MediatorLiveData<List<SavedItemCardDataWithLabels>>()
private var itemsLiveDataInternal = dataService.db.savedItemDao().filteredLibraryData(
archiveFilter = 1,
sortKey = "newest",
requiredLabels = listOf(),
excludedLabels = listOf(),
allowedContentReaders = listOf("WEB", "PDF", "EPUB")
)
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
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<List<SavedItemLabel>>(listOf())
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(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<SavedItemLabel>) {
@ -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()) {

View file

@ -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<SavedItemWithLabelsAndHighlights?>
get() = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
fun handleSavedItemAction(itemID: String, action: SavedItemAction)
}

View file

@ -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()
}
}

View file

@ -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<TypeaheadCardData> 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<SavedItemWithLabelsAndHighlights> 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) }
)
}
}
}

View file

@ -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<String>(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<List<TypeaheadCardData>>(listOf())
val itemsLiveData = MediatorLiveData<List<SavedItemWithLabelsAndHighlights>>()
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(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<SavedItemLabel>) {
// 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
}
}

View file

@ -134,13 +134,16 @@ class PDFReaderActivity: AppCompatActivity(), DocumentListener, TextSelectionMan
private fun loadHighlights(highlights: List<Highlight>) {
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)
}
}
}
}

View file

@ -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,
)

View file

@ -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() }) {

View file

@ -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(

View file

@ -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)
}

View file

@ -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<SavedItemLabel>, 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<SavedItemLabel>, 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<SavedItemLabel>, 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)")
}
}

View file

@ -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)
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M200,128a8,8 0,0 1,-8 8L64,136a8,8 0,0 1,0 -16L192,120A8,8 0,0 1,200 128ZM232,72L24,72a8,8 0,0 0,0 16L232,88a8,8 0,0 0,0 -16ZM152,168L104,168a8,8 0,0 0,0 16h48a8,8 0,0 0,0 -16Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M208,32L48,32A16,16 0,0 0,32 48L32,208a16,16 0,0 0,16 16L208,224a16,16 0,0 0,16 -16L224,48A16,16 0,0 0,208 32ZM208,48L208,152h-28.7A15.86,15.86 0,0 0,168 156.69L148.69,176L107.31,176L88,156.69A15.86,15.86 0,0 0,76.69 152L48,152L48,48ZM208,208L48,208L48,168L76.69,168L96,187.31A15.86,15.86 0,0 0,107.31 192h41.38A15.86,15.86 0,0 0,160 187.31L179.31,168L208,168v40ZM90.34,125.66a8,8 0,0 1,11.32 -11.32L120,132.69L120,72a8,8 0,0 1,16 0v60.69l18.34,-18.35a8,8 0,0 1,11.32 11.32l-32,32a8,8 0,0 1,-11.32 0Z"
android:fillColor="#000000"/>
</vector>

View file

@ -4,4 +4,10 @@
<color name="white">#FFFFFFFF</color>
<color name="cloud">#F7F7F7</color>
<color name="gray_B7B7B7">#B7B7B7</color>
<color name="gray_898989">#898989</color>
<color name="green_55B938">#55B938</color>
<color name="gray_EBEBEB">#EBEBEB</color>
<color name="yellow_E6E4BF">#E6E4BF</color>
</resources>