mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
New library item cards, fix issue with read position
This commit is contained in:
parent
557bb2ba7b
commit
4fd4d5a4db
31 changed files with 1935 additions and 78 deletions
|
|
@ -58,7 +58,7 @@ struct FeedCardNavigationLink: View {
|
|||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
FeedCard(item: item, viewer: dataService.currentViewer)
|
||||
LibraryItemCard(item: item, viewer: dataService.currentViewer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import Views
|
|||
struct HomeFeedContainerView: View {
|
||||
@State var hasHighlightMutations = false
|
||||
@State var searchPresented = false
|
||||
@State var addLinkPresented = false
|
||||
@State var settingsPresented = false
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
|
|
@ -129,16 +131,18 @@ import Views
|
|||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
if UIDevice.isIPhone {
|
||||
NavigationLink(
|
||||
destination: { ProfileView() },
|
||||
label: {
|
||||
Image(systemName: "person.circle")
|
||||
.resizable()
|
||||
.frame(width: 22, height: 22)
|
||||
.padding(.vertical, 16)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
}
|
||||
)
|
||||
Menu(content: {
|
||||
Button(action: { settingsPresented = true }, label: {
|
||||
Label(LocalText.genericProfile, systemImage: "person.circle")
|
||||
})
|
||||
Button(action: { addLinkPresented = true }, label: {
|
||||
Label("Add Link", systemImage: "plus.square")
|
||||
})
|
||||
}, label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.frame(width: 24, height: 24)
|
||||
})
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
|
|
@ -189,6 +193,16 @@ import Views
|
|||
.fullScreenCover(isPresented: $searchPresented) {
|
||||
LibrarySearchView(homeFeedViewModel: self.viewModel)
|
||||
}
|
||||
.sheet(isPresented: $addLinkPresented) {
|
||||
NavigationView {
|
||||
LibraryAddLinkView()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $settingsPresented) {
|
||||
NavigationView {
|
||||
ProfileView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel.items.isEmpty {
|
||||
loadItems(isRefresh: false)
|
||||
|
|
@ -364,6 +378,7 @@ import Views
|
|||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.listRowInsets(.init(top: 0, leading: 8, bottom: 8, trailing: 8))
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
|
|
@ -407,8 +422,9 @@ import Views
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(0)
|
||||
.listStyle(PlainListStyle())
|
||||
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.",
|
||||
isPresented: $confirmationShown) {
|
||||
Button("Remove Item", role: .destructive) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
|
||||
import Introspect
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Views
|
||||
|
||||
@MainActor final class LibraryAddLinkViewModel: NSObject, ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var showErrorMessage: Bool = false
|
||||
|
||||
func addLink(dataService: DataService, newLinkURL: String, dismiss: DismissAction) {
|
||||
isLoading = true
|
||||
Task {
|
||||
if URL(string: newLinkURL) == nil {
|
||||
error("Invalid link")
|
||||
} else {
|
||||
let result = try? await dataService.saveURL(id: UUID().uuidString, url: newLinkURL)
|
||||
if result == nil {
|
||||
error("Error adding link")
|
||||
} else {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func error(_ msg: String) {
|
||||
errorMessage = msg
|
||||
showErrorMessage = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryAddLinkView: View {
|
||||
@StateObject var viewModel = LibraryAddLinkViewModel()
|
||||
|
||||
@State var newLinkURL: String = ""
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
enum FocusField: Hashable {
|
||||
case addLinkEditor
|
||||
}
|
||||
|
||||
@FocusState private var focusedField: FocusField?
|
||||
|
||||
var body: some View {
|
||||
innerBody
|
||||
.navigationTitle("Add Link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
focusedField = .addLinkEditor
|
||||
}
|
||||
}
|
||||
|
||||
var innerBody: some View {
|
||||
Form {
|
||||
TextField("Add Link", text: $newLinkURL)
|
||||
.keyboardType(.URL)
|
||||
.textFieldStyle(StandardTextFieldStyle())
|
||||
.focused($focusedField, equals: .addLinkEditor)
|
||||
|
||||
Button(action: {
|
||||
if let url = UIPasteboard.general.url {
|
||||
newLinkURL = url.absoluteString
|
||||
} else {
|
||||
viewModel.error("No URL on pasteboard")
|
||||
}
|
||||
}, label: {
|
||||
Text("Get from pasteboard")
|
||||
})
|
||||
}
|
||||
.navigationTitle("Add Link")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
dismissButton
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
viewModel.isLoading ? AnyView(ProgressView()) : AnyView(addButton)
|
||||
}
|
||||
}
|
||||
.alert(viewModel.errorMessage,
|
||||
isPresented: $viewModel.showErrorMessage) {
|
||||
Button(LocalText.genericOk, role: .cancel) { viewModel.showErrorMessage = false }
|
||||
}
|
||||
}
|
||||
|
||||
var addButton: some View {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.addLink(dataService: dataService, newLinkURL: newLinkURL, dismiss: dismiss)
|
||||
},
|
||||
label: { Text("Add").bold() }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ struct ProfileView: View {
|
|||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.openURL) var openURL
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@StateObject private var viewModel = ProfileContainerViewModel()
|
||||
|
||||
|
|
@ -69,6 +70,13 @@ struct ProfileView: View {
|
|||
Form {
|
||||
innerBody
|
||||
}
|
||||
.navigationTitle(LocalText.genericProfile)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
|
|
@ -77,6 +85,13 @@ struct ProfileView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text(LocalText.genericClose) }
|
||||
)
|
||||
}
|
||||
|
||||
private var accountSection: some View {
|
||||
Section {
|
||||
NavigationLink(destination: LabelsView()) {
|
||||
|
|
@ -171,7 +186,6 @@ struct ProfileView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(LocalText.genericProfile)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,44 +46,48 @@ struct SubscriptionsView: View {
|
|||
@State private var progressViewOpacity = 0.0
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.opacity(progressViewOpacity)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
|
||||
progressViewOpacity = 1
|
||||
Group {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.opacity(progressViewOpacity)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
|
||||
progressViewOpacity = 1
|
||||
}
|
||||
}
|
||||
.task { await viewModel.loadSubscriptions(dataService: dataService) }
|
||||
} else if viewModel.hasNetworkError {
|
||||
VStack {
|
||||
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
|
||||
Button(
|
||||
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
|
||||
label: { Text(LocalText.genericRetry) }
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
} else if viewModel.subscriptions.isEmpty {
|
||||
VStack(alignment: .center) {
|
||||
Spacer()
|
||||
Text(LocalText.subscriptionsNone)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
.task { await viewModel.loadSubscriptions(dataService: dataService) }
|
||||
} else if viewModel.hasNetworkError {
|
||||
VStack {
|
||||
Text(LocalText.subscriptionsErrorRetrieving).multilineTextAlignment(.center)
|
||||
Button(
|
||||
action: { Task { await viewModel.loadSubscriptions(dataService: dataService) } },
|
||||
label: { Text(LocalText.genericRetry) }
|
||||
)
|
||||
.buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
} else if viewModel.subscriptions.isEmpty {
|
||||
VStack(alignment: .center) {
|
||||
Spacer()
|
||||
Text(LocalText.subscriptionsNone)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
Form {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.navigationTitle("Subscriptions")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ struct WebReader: PlatformViewRepresentable {
|
|||
let articleContent: ArticleContent
|
||||
let openLinkAction: (URL) -> Void
|
||||
let tapHandler: () -> Void
|
||||
let scrollPercentHandler: (Int) -> Void
|
||||
let webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void
|
||||
let navBarVisibilityRatioUpdater: (Double) -> Void
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ struct WebReader: PlatformViewRepresentable {
|
|||
webView.configuration.userContentController = contentController
|
||||
webView.configuration.userContentController.removeAllScriptMessageHandlers()
|
||||
|
||||
webView.uiDelegate
|
||||
#if os(iOS)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
|
|
@ -74,6 +76,7 @@ struct WebReader: PlatformViewRepresentable {
|
|||
context.coordinator.linkHandler = openLinkAction
|
||||
context.coordinator.webViewActionHandler = webViewActionHandler
|
||||
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
|
||||
context.coordinator.scrollPercentHandler = scrollPercentHandler
|
||||
context.coordinator.updateShowBottomBar = { newValue in
|
||||
self.showBottomBar = newValue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ struct WebReaderContainerView: View {
|
|||
@State private var errorAlertMessage: String?
|
||||
@State private var showErrorAlertMessage = false
|
||||
@State private var showRecommendSheet = false
|
||||
@State private var lastScrollPercentage: Int?
|
||||
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State var displayLinkSheet = false
|
||||
|
|
@ -56,6 +57,10 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
}
|
||||
|
||||
func scrollPercentHandler(percent: Int) {
|
||||
lastScrollPercentage = percent
|
||||
}
|
||||
|
||||
func onHighlightListViewDismissal() {
|
||||
// Reload the web view if mutation happened in highlights list modal
|
||||
guard hasPerformedHighlightMutations else { return }
|
||||
|
|
@ -369,6 +374,7 @@ struct WebReaderContainerView: View {
|
|||
#endif
|
||||
},
|
||||
tapHandler: tapHandler,
|
||||
scrollPercentHandler: scrollPercentHandler,
|
||||
webViewActionHandler: webViewActionHandler,
|
||||
navBarVisibilityRatioUpdater: {
|
||||
navBarVisibilityRatio = $0
|
||||
|
|
@ -381,6 +387,20 @@ struct WebReaderContainerView: View {
|
|||
showBottomBar: $showBottomBar,
|
||||
showHighlightAnnotationModal: $showHighlightAnnotationModal
|
||||
)
|
||||
.onAppear {
|
||||
if item.isUnread {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
// if let lastScrollPercentage = self.lastScrollPercentage {
|
||||
// dataService.updateLinkReadingProgress(
|
||||
// itemID: item.unwrappedID,
|
||||
// readingProgress: Double(lastScrollPercentage),
|
||||
// anchorIndex: 0
|
||||
// )
|
||||
// }
|
||||
}
|
||||
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
|
||||
Button(action: {
|
||||
if let linkToOpen = linkToOpen {
|
||||
|
|
@ -495,9 +515,13 @@ struct WebReaderContainerView: View {
|
|||
readerSettingsChangedTransactionID = UUID()
|
||||
}
|
||||
#endif
|
||||
.onAppear {
|
||||
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
|
||||
}
|
||||
.onDisappear {
|
||||
try? WebViewManager.shared().dispatchEvent(.saveReadPosition)
|
||||
// Clear the shared webview content when exiting
|
||||
WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
// WebViewManager.shared().loadHTMLString("<html></html>", baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ typealias WKScriptMessageReplyHandler = (Any?, String?) -> Void
|
|||
final class WebReaderCoordinator: NSObject {
|
||||
var webViewActionHandler: (WKScriptMessage, WKScriptMessageReplyHandler?) -> Void = { _, _ in }
|
||||
var linkHandler: (URL) -> Void = { _ in }
|
||||
var scrollPercentHandler: ((Int) -> Void) = { _ in }
|
||||
var needsReload = false
|
||||
var lastSavedAnnotationID: UUID?
|
||||
var previousReaderSettingsChangedUUID: UUID?
|
||||
|
|
@ -128,6 +129,9 @@ extension WebReaderCoordinator: WKNavigationDelegate {
|
|||
} else {
|
||||
updateShowBottomBar(false)
|
||||
}
|
||||
|
||||
let percent = Int(((yOffset + scrollView.visibleSize.height) / scrollView.contentSize.height) * 100)
|
||||
scrollPercentHandler(max(0, min(percent, 100)))
|
||||
}
|
||||
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
<attribute name="tempPDFURL" optional="YES" attributeType="URI"/>
|
||||
<attribute name="title" attributeType="String"/>
|
||||
<attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
<attribute name="wordsCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="highlights" toMany="YES" deletionRule="Cascade" destinationEntity="Highlight" inverseName="linkedItem" inverseEntity="Highlight"/>
|
||||
<relationship name="labels" toMany="YES" deletionRule="Nullify" destinationEntity="LinkedItemLabel" inverseName="linkedItems" inverseEntity="LinkedItemLabel"/>
|
||||
<relationship name="recommendations" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="Recommendation" inverseName="linkeditem" inverseEntity="Recommendation"/>
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ public struct JSONArticle: Decodable {
|
|||
public let url: String
|
||||
public let isArchived: Bool
|
||||
public let language: String?
|
||||
public let wordsCount: Int?
|
||||
}
|
||||
|
||||
public extension LinkedItem {
|
||||
|
|
@ -69,6 +70,10 @@ public extension LinkedItem {
|
|||
(labels?.count ?? 0) > 0
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
readingProgress <= 0
|
||||
}
|
||||
|
||||
var isRead: Bool {
|
||||
readingProgress >= 0.98
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@ extension DataService {
|
|||
guard let self = self else { return }
|
||||
guard let linkedItem = LinkedItem.lookup(byID: itemID, inContext: self.backgroundContext) else { return }
|
||||
|
||||
print("updateLinkReadingProgress", readingProgress, anchorIndex)
|
||||
linkedItem.update(
|
||||
inContext: self.backgroundContext,
|
||||
newReadingProgress: readingProgress,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ extension DataService {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
),
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ private let libraryArticleSelection = Selection.Article {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
|
|
@ -313,6 +314,7 @@ private let searchItemSelection = Selection.SearchItem {
|
|||
contentReader: try $0.contentReader().rawValue,
|
||||
originalHtml: nil,
|
||||
language: try $0.language(),
|
||||
wordsCount: try $0.wordsCount(),
|
||||
recommendations: try $0.recommendations(selection: recommendationSelection.list.nullable) ?? [],
|
||||
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ struct InternalLinkedItem {
|
|||
let contentReader: String?
|
||||
let originalHtml: String?
|
||||
let language: String?
|
||||
let wordsCount: Int?
|
||||
let recommendations: [InternalRecommendation]
|
||||
var labels: [InternalLinkedItemLabel]
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ struct InternalLinkedItem {
|
|||
linkedItem.contentReader = contentReader
|
||||
linkedItem.originalHtml = originalHtml
|
||||
linkedItem.language = language
|
||||
linkedItem.wordsCount = Int64(wordsCount ?? 0)
|
||||
|
||||
// Remove existing labels in case a label had been deleted
|
||||
if let existingLabels = linkedItem.labels {
|
||||
|
|
@ -142,6 +144,7 @@ extension JSONArticle {
|
|||
contentReader: contentReader,
|
||||
originalHtml: nil,
|
||||
language: language,
|
||||
wordsCount: wordsCount,
|
||||
recommendations: [], // TODO:
|
||||
labels: []
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,4 +28,5 @@ public enum UserDefaultKey: String {
|
|||
case notificationsEnabled
|
||||
case deviceTokenID
|
||||
case shouldPromptCommunityModal
|
||||
case userWordsPerMinute
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ public enum WebViewDispatchEvent {
|
|||
case speakingSection(anchorIdx: String)
|
||||
case updateLabels(labels: String)
|
||||
case updateTitle(title: String)
|
||||
case saveReadPosition
|
||||
|
||||
var script: String {
|
||||
get throws {
|
||||
|
|
@ -463,6 +464,8 @@ public enum WebViewDispatchEvent {
|
|||
return "updateTitle"
|
||||
case .handleAutoHighlightModeChange:
|
||||
return "handleAutoHighlightModeChange"
|
||||
case .saveReadPosition:
|
||||
return "saveReadPosition"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -503,10 +506,17 @@ public enum WebViewDispatchEvent {
|
|||
}
|
||||
case let .speakingSection(anchorIdx: anchorIdx):
|
||||
return "event.anchorIdx = '\(anchorIdx)';"
|
||||
case .annotate, .highlight, .setHighlightLabels, .share, .remove, .copyHighlight, .dismissHighlight:
|
||||
return ""
|
||||
case let .handleAutoHighlightModeChange(isEnabled: isEnabled):
|
||||
return "event.enableHighlightOnRelease = '\(isEnabled ? "on" : "off")';"
|
||||
case .annotate,
|
||||
.highlight,
|
||||
.setHighlightLabels,
|
||||
.share,
|
||||
.remove,
|
||||
.copyHighlight,
|
||||
.dismissHighlight,
|
||||
.saveReadPosition:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ public extension Color {
|
|||
static var appButtonBackground: Color { Color("_buttonBackground", bundle: .module) }
|
||||
static var appTextDefault: Color { Color("_utilityTextDefault", bundle: .module) }
|
||||
static var appPrimaryBackground: Color { Color("_appPrimaryBackground", bundle: .module) }
|
||||
static var checkmarkBlue: Color { Color("_checkmarkBlue", bundle: .module) }
|
||||
static var indicatorBlue: Color { Color("_indicatorBlue", bundle: .module) }
|
||||
static var webControlButtonBackground: Color { Color("_webControlButtonBackground", bundle: .module) }
|
||||
|
||||
// Apple system UIColor equivalents
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.294",
|
||||
"green" : "0.843",
|
||||
"red" : "0.196"
|
||||
"blue" : "0.220",
|
||||
"green" : "0.725",
|
||||
"red" : "0.333"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.294",
|
||||
"green" : "0.843",
|
||||
"red" : "0.196"
|
||||
"blue" : "0.220",
|
||||
"green" : "0.725",
|
||||
"red" : "0.333"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0x84",
|
||||
"red" : "0x0A"
|
||||
"blue" : "0xF7",
|
||||
"green" : "0x82",
|
||||
"red" : "0x3A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
|
|
@ -23,9 +23,9 @@
|
|||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0x84",
|
||||
"red" : "0x0A"
|
||||
"blue" : "0xF7",
|
||||
"green" : "0x82",
|
||||
"red" : "0x3A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
221
apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift
Normal file
221
apple/OmnivoreKit/Sources/Views/FeedItem/LibraryItemCard.swift
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
import Utils
|
||||
|
||||
public struct LibraryItemCard: View {
|
||||
let viewer: Viewer?
|
||||
let tapHandler: () -> Void
|
||||
@ObservedObject var item: LinkedItem
|
||||
|
||||
public init(item: LinkedItem, viewer: Viewer?, tapHandler: @escaping () -> Void = {}) {
|
||||
self.item = item
|
||||
self.viewer = viewer
|
||||
self.tapHandler = tapHandler
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
readIndicator
|
||||
articleInfo
|
||||
imageBox
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if item.hasLabels {
|
||||
labels
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
var isFullyRead: Bool {
|
||||
item.readingProgress > 95
|
||||
}
|
||||
|
||||
var isPartiallyRead: Bool {
|
||||
Int(item.readingProgress) > 0
|
||||
}
|
||||
|
||||
var readIndicator: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.foregroundColor(item.readingProgress > 0 ? .clear : .indicatorBlue)
|
||||
.frame(width: 9, height: 9, alignment: .topLeading)
|
||||
.padding(.top, 22)
|
||||
.padding(.leading, 0)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
.padding(0)
|
||||
.frame(width: 20)
|
||||
}
|
||||
|
||||
var readingSpeed: Int64 {
|
||||
var result = UserDefaults.standard.integer(forKey: UserDefaultKey.userWordsPerMinute.rawValue)
|
||||
if result <= 0 {
|
||||
result = 235
|
||||
}
|
||||
return Int64(result)
|
||||
}
|
||||
|
||||
var estimatedReadingTime: String {
|
||||
if item.wordsCount > 0 {
|
||||
let readLen = max(1, item.wordsCount / readingSpeed)
|
||||
return "\(readLen) MIN READ • "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var readingProgress: String {
|
||||
// If there is no wordsCount don't show progress because it will make no sense
|
||||
if item.wordsCount > 0 {
|
||||
return "\(String(format: "%d", Int(item.readingProgress)))%"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var highlightsText: String {
|
||||
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 ""
|
||||
}
|
||||
|
||||
var readInfo: some View {
|
||||
AnyView(HStack {
|
||||
Text("\(estimatedReadingTime)")
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
+
|
||||
Text("\(readingProgress)")
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color(hex: "#898989"))
|
||||
|
||||
+
|
||||
Text("\(highlightsText)")
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
|
||||
+
|
||||
Text("\(notesText)")
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading))
|
||||
}
|
||||
|
||||
var imageBox: some View {
|
||||
Group {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
if let image = phase.image {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 55, height: 73)
|
||||
.cornerRadius(4)
|
||||
.padding(.top, 2)
|
||||
} else {
|
||||
Color.systemBackground
|
||||
.frame(width: 55, height: 73)
|
||||
.cornerRadius(4)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.opacity(isFullyRead ? 0.4 : 1.0)
|
||||
}
|
||||
|
||||
var bylineStr: String {
|
||||
// It seems like it could be cleaner just having author, instead of
|
||||
// concating, maybe we fall back
|
||||
if let author = item.author {
|
||||
return author
|
||||
} else if let publisherDisplayName = item.publisherDisplayName {
|
||||
return publisherDisplayName
|
||||
}
|
||||
|
||||
return ""
|
||||
|
||||
// var str = ""
|
||||
// if let author = item.author {
|
||||
// str += author
|
||||
// }
|
||||
//
|
||||
// if item.author != nil, item.publisherDisplayName != nil {
|
||||
// str += ", "
|
||||
// }
|
||||
//
|
||||
// if let publisherDisplayName = item.publisherDisplayName {
|
||||
// str += publisherDisplayName
|
||||
// }
|
||||
//
|
||||
// return str
|
||||
}
|
||||
|
||||
var byLine: some View {
|
||||
Text(bylineStr)
|
||||
.font(Font.system(size: 15, weight: .regular))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
public var articleInfo: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
readInfo
|
||||
|
||||
Text(item.unwrappedTitle)
|
||||
.font(Font.system(size: 18, weight: .semibold))
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(isFullyRead ? Color(hex: "#898989") : .appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
byLine
|
||||
}
|
||||
.padding(0)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
|
||||
var labels: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(item.sortedLabels, id: \.self) {
|
||||
TextChip(feedItemLabel: $0)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}.introspectScrollView { scrollView in
|
||||
scrollView.bounces = false
|
||||
}
|
||||
.padding(.top, 0)
|
||||
.padding(.leading, 20)
|
||||
#if os(macOS)
|
||||
.onTapGesture {
|
||||
tapHandler()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,12 @@ public enum LocalText {
|
|||
NSLocalizedString(key, bundle: .module, comment: comment ?? "no comment provided by developer")
|
||||
}
|
||||
|
||||
public static func pluralizedText(key: String, count: Int) -> String {
|
||||
let format = NSLocalizedString(key, bundle: .module, comment: "")
|
||||
print("key", key, "format", format)
|
||||
return String.localizedStringWithFormat(format, count)
|
||||
}
|
||||
|
||||
// Share extension
|
||||
public static let saveArticleSavedState = localText(key: "saveArticleSavedState")
|
||||
public static let saveArticleProcessingState = localText(key: "saveArticleProcessingState")
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -138,7 +138,6 @@
|
|||
//"library.by.author.suffix" = "by \(author)" // unused
|
||||
//"Recommended by \(byStr) in \(inStr)" // unused
|
||||
|
||||
|
||||
// Generic
|
||||
"genericSnooze" = "Snooze";
|
||||
"genericClose" = "Close";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>number_of_highlights</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@value@</string>
|
||||
<key>value</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d highlight</string>
|
||||
<key>other</key>
|
||||
<string>%d highlights</string>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>number_of_notes</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@value@</string>
|
||||
<key>value</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d note</string>
|
||||
<key>other</key>
|
||||
<string>%d notes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -49,7 +49,6 @@ public struct TextChip: View {
|
|||
return .white
|
||||
}
|
||||
|
||||
print(" Luminance: ", luminance, "for color", color.hex)
|
||||
return luminance > 0.35 ? .black : .white
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const mutation = async (name, input) => {
|
|||
actionID: name,
|
||||
...input,
|
||||
})
|
||||
console.log('action result', result, result.result)
|
||||
console.log('action result', name, result, result.result)
|
||||
return result.result
|
||||
} else {
|
||||
// Send android a message
|
||||
|
|
|
|||
|
|
@ -17,23 +17,18 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
|
|||
const g = (bigint >> 8) & 255
|
||||
const b = bigint & 255
|
||||
|
||||
console.log(' -- ', r, g, b, 'for', hex)
|
||||
return [r, g, b]
|
||||
}
|
||||
|
||||
const parsed = parseToRgba(props.color)
|
||||
console.log(' -- parsed: ', parsed, 'for', props.color)
|
||||
function f(x: number) {
|
||||
const channel = x / 255
|
||||
return channel <= 0.03928
|
||||
? channel / 12.92
|
||||
: Math.pow((channel + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
console.log(' -- parts: ', f(parsed[0]), f(parsed[1]), f(parsed[2]))
|
||||
|
||||
const luminance = getLuminance(props.color)
|
||||
const backgroundColor = hexToRgb(props.color)
|
||||
console.log('luminance', luminance, 'for color: ', props.color)
|
||||
const textColor = luminance > 0.5 ? '#000000' : '#ffffff'
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
useScrollWatcher((changeset: ScrollOffsetChangeset) => {
|
||||
if (window && window.document.scrollingElement) {
|
||||
const newReadingProgress =
|
||||
window.scrollY / window.document.scrollingElement.scrollHeight
|
||||
(window.scrollY + window.innerHeight) /
|
||||
window.document.scrollingElement.scrollHeight
|
||||
const adjustedReadingProgress =
|
||||
newReadingProgress > 0.92 ? 1 : newReadingProgress
|
||||
debouncedSetReadingProgress(adjustedReadingProgress * 100)
|
||||
|
|
@ -119,6 +120,22 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const saveReadPosition = () => {
|
||||
console.log(
|
||||
'saving read position from article: ',
|
||||
readingProgress,
|
||||
readingAnchorIndex
|
||||
)
|
||||
}
|
||||
|
||||
document.addEventListener('saveReadPosition', saveReadPosition)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('saveReadPosition', saveReadPosition)
|
||||
}
|
||||
}, [readingProgress, readingAnchorIndex])
|
||||
|
||||
// Scroll to initial anchor position
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
|
|
|||
|
|
@ -249,6 +249,12 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
}
|
||||
}
|
||||
|
||||
const saveReadPosition = () => {
|
||||
console.log('saving read position')
|
||||
}
|
||||
|
||||
document.addEventListener('saveReadPosition', saveReadPosition)
|
||||
|
||||
document.addEventListener('updateFontFamily', updateFontFamily)
|
||||
document.addEventListener('updateLineHeight', updateLineHeight)
|
||||
document.addEventListener(
|
||||
|
|
@ -292,6 +298,7 @@ export function ArticleContainer(props: ArticleContainerProps): JSX.Element {
|
|||
'handleAutoHighlightModeChange',
|
||||
updateHighlightMode
|
||||
)
|
||||
document.removeEventListener('saveReadPosition', saveReadPosition)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ export const { styled, css, theme, getCssText, globalCss, keyframes, config } =
|
|||
grayProgressBackground: '#FFFFFF',
|
||||
|
||||
// Semantic Colors
|
||||
highlightBackground: '255, 255, 0',
|
||||
highlightBackground: '255, 210, 52',
|
||||
recommendedHighlightBackground: '#E5FFE5',
|
||||
highlight: '#FFD234',
|
||||
highlightText: '#3D3D3D',
|
||||
|
|
|
|||
Loading…
Reference in a new issue