Merge pull request #2471 from omnivore-app/feat/ios-tab-view
Multi tab iOS client
|
|
@ -40,6 +40,8 @@ import Utils
|
|||
@State var readerView: Bool = false
|
||||
@State private var shareLink: ShareLink?
|
||||
@State private var errorMessage: String?
|
||||
@State private var showNotebookView = false
|
||||
@State private var hasPerformedHighlightMutations = false
|
||||
|
||||
init(viewModel: PDFViewerViewModel) {
|
||||
self.viewModel = viewModel
|
||||
|
|
@ -117,6 +119,12 @@ import Utils
|
|||
style: .plain,
|
||||
target: controller.searchButtonItem.target,
|
||||
action: controller.searchButtonItem.action
|
||||
),
|
||||
UIBarButtonItem(
|
||||
image: UIImage(named: "notebook", in: Bundle(url: ViewsPackage.bundleURL), with: nil),
|
||||
style: .plain,
|
||||
target: coordinator,
|
||||
action: #selector(PDFViewCoordinator.toggleNotebookView)
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -183,6 +191,15 @@ import Utils
|
|||
.sheet(item: $shareLink) {
|
||||
ShareSheet(activityItems: [$0.url])
|
||||
}
|
||||
.fullScreenCover(isPresented: $showNotebookView, onDismiss: onNotebookViewDismissal) {
|
||||
NotebookView(
|
||||
itemObjectID: viewModel.pdfItem.objectID,
|
||||
hasHighlightMutations: $hasPerformedHighlightMutations,
|
||||
onDeleteHighlight: { highlightId in
|
||||
coordinator.removeHighlightFromPDF(highlightId: highlightId)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else if let errorMessage = errorMessage {
|
||||
Text(errorMessage)
|
||||
} else {
|
||||
|
|
@ -202,6 +219,12 @@ import Utils
|
|||
}
|
||||
}
|
||||
|
||||
func onNotebookViewDismissal() {
|
||||
guard hasPerformedHighlightMutations else { return }
|
||||
|
||||
hasPerformedHighlightMutations.toggle()
|
||||
}
|
||||
|
||||
class PDFViewCoordinator: NSObject, PDFDocumentViewControllerDelegate, PDFViewControllerDelegate {
|
||||
let document: Document
|
||||
let viewModel: PDFViewerViewModel
|
||||
|
|
@ -239,6 +262,22 @@ import Utils
|
|||
}.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func removeHighlightFromPDF(highlightId: String) {
|
||||
for pageIndex in 0 ..< document.pageCount {
|
||||
let pageHighlights = document.annotations(at: pageIndex, type: HighlightAnnotation.self)
|
||||
|
||||
for annotation in pageHighlights {
|
||||
if let customHighlight = annotation.customData?["omnivoreHighlight"] as? [String: String] {
|
||||
if customHighlight["id"]?.lowercased() == highlightId {
|
||||
if !document.remove(annotations: [annotation]) {
|
||||
Snackbar.show(message: "Error removing highlight")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func highlightsOverlap(left: HighlightAnnotation, right: HighlightAnnotation) -> Bool {
|
||||
for rect in left.rects ?? [] {
|
||||
for hrrect in right.rects ?? [] {
|
||||
|
|
@ -382,6 +421,12 @@ import Utils
|
|||
}
|
||||
}
|
||||
|
||||
@objc public func toggleNotebookView() {
|
||||
if let viewer = self.viewer {
|
||||
viewer.showNotebookView = !viewer.showNotebookView
|
||||
}
|
||||
}
|
||||
|
||||
func shortHighlightIds(_ annotations: [HighlightAnnotation]) -> [String] {
|
||||
annotations.compactMap { ($0.customData?["omnivoreHighlight"] as? [String: String])?["shortId"] }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
|||
var title: String {
|
||||
switch self {
|
||||
case .feed:
|
||||
return "Home"
|
||||
return "Library"
|
||||
case .profile:
|
||||
return LocalText.genericProfile
|
||||
}
|
||||
|
|
@ -25,9 +25,9 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
|||
var image: Image {
|
||||
switch self {
|
||||
case .feed:
|
||||
return .homeTab
|
||||
return Image(systemName: "book")
|
||||
case .profile:
|
||||
return .profileTab
|
||||
return Image(systemName: "person.circle")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -35,10 +35,10 @@ enum PrimaryContentCategory: Identifiable, Hashable, Equatable {
|
|||
Label { Text(title) } icon: { image.renderingMode(.template) }
|
||||
}
|
||||
|
||||
@ViewBuilder var destinationView: some View {
|
||||
@MainActor @ViewBuilder var destinationView: some View {
|
||||
switch self {
|
||||
case .feed:
|
||||
HomeView()
|
||||
LibraryListView()
|
||||
case .profile:
|
||||
ProfileView()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,13 +84,6 @@ public final class Services {
|
|||
Task {
|
||||
do {
|
||||
let fetchedItemCount = try await services.dataService.fetchLinkedItemsBackgroundTask()
|
||||
EventTracker.track(
|
||||
.backgroundFetch(
|
||||
jobStatus: .success,
|
||||
itemCount: fetchedItemCount,
|
||||
secondsElapsed: Int(startTime.timeIntervalSinceNow)
|
||||
)
|
||||
)
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
EventTracker.track(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Services
|
|||
import Views
|
||||
|
||||
extension Snackbar {
|
||||
static func show(message: String) {
|
||||
NSNotification.operationSuccess(message: message)
|
||||
static func show(message: String, undoAction: (() -> Void)? = nil) {
|
||||
NSNotification.operationSuccess(message: message, undoAction: undoAction)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
159
apple/OmnivoreKit/Sources/App/Views/BriefingView.swift
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor final class BriefingViewModel: ObservableObject {
|
||||
@Published var item: LinkedItem?
|
||||
|
||||
func loadItem(articleId: String, dataService: DataService) async {
|
||||
let item = await dataService.viewContext.perform {
|
||||
LinkedItem.lookup(byID: articleId, inContext: dataService.viewContext)
|
||||
// dataService.viewContext.object(with: linkedItemObjectID) as? LinkedItem
|
||||
}
|
||||
|
||||
if let item = item {
|
||||
self.item = item
|
||||
}
|
||||
}
|
||||
|
||||
// func handleArchiveAction(dataService: DataService) {
|
||||
// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
// dataService.archiveLink(objectID: objectID, archived: !isItemArchived)
|
||||
// showInSnackbar(!isItemArchived ? "Link archived" : "Link moved to Inbox")
|
||||
// }
|
||||
//
|
||||
// func handleDeleteAction(dataService: DataService) {
|
||||
// guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
// showInSnackbar("Link removed")
|
||||
// dataService.removeLink(objectID: objectID)
|
||||
// }
|
||||
//
|
||||
// func updateItemReadStatus(dataService: DataService) {
|
||||
// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
//
|
||||
// dataService.updateLinkReadingProgress(
|
||||
// itemID: itemID,
|
||||
// readingProgress: isItemRead ? 0 : 100,
|
||||
// anchorIndex: 0
|
||||
// )
|
||||
// }
|
||||
|
||||
// private func trackReadEvent() {
|
||||
// guard let itemID = item?.unwrappedID ?? pdfItem?.itemID else { return }
|
||||
// guard let slug = item?.unwrappedSlug ?? pdfItem?.slug else { return }
|
||||
// guard let originalArticleURL = item?.unwrappedPageURLString ?? pdfItem?.originalArticleURL else { return }
|
||||
//
|
||||
// EventTracker.track(
|
||||
// .linkRead(
|
||||
// linkID: itemID,
|
||||
// slug: slug,
|
||||
// originalArticleURL: originalArticleURL
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
}
|
||||
|
||||
struct BriefingView: View {
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
|
||||
static let navBarHeight = 50.0
|
||||
let articleId: String
|
||||
|
||||
@StateObject private var viewModel = BriefingViewModel()
|
||||
@State private var showFontSizePopover = false
|
||||
@State private var showTitleEdit = false
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
var removeLinkToolbarItem: some View {
|
||||
Button(
|
||||
action: { print("delete item action") },
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack { // Using ZStack so .task can be used on if/else body
|
||||
if let item = viewModel.item {
|
||||
WebReaderContainerView(item: item)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadItem(articleId: articleId, dataService: dataService)
|
||||
NotificationCenter.default.post(Notification(name: Notification.Name("ReaderSettingsChanged")))
|
||||
// static var readerSettingsChangedPublisher: NotificationCenter.Publisher {
|
||||
// NotificationCenter.default.publisher(for: ReaderSettingsChanged)
|
||||
// }
|
||||
}
|
||||
#if os(iOS)
|
||||
.navigationBarHidden(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: { showTitleEdit = true },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
)
|
||||
// Button(
|
||||
// action: { viewModel.handleArchiveAction(dataService: dataService) },
|
||||
// label: {
|
||||
// Label(
|
||||
// viewModel.isItemArchived ? "Unarchive" : "Archive",
|
||||
// systemImage: viewModel.isItemArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
Button(
|
||||
action: { showDeleteConfirmation = true },
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
|
||||
// Button("Remove Link", role: .destructive) {
|
||||
// viewModel.handleDeleteAction(dataService: dataService)
|
||||
// }
|
||||
Button(LocalText.cancelGeneric, role: .cancel, action: {})
|
||||
}
|
||||
.sheet(isPresented: $showTitleEdit) {
|
||||
if let item = viewModel.item {
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
typealias DeleteHighlightAction = (String) -> Void
|
||||
|
||||
struct NotebookView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
|
@ -21,6 +23,7 @@
|
|||
@State var setLabelsHighlight: Highlight?
|
||||
@State var showShareView: Bool = false
|
||||
@State var showConfirmNoteDelete = false
|
||||
@State var onDeleteHighlight: DeleteHighlightAction?
|
||||
|
||||
var emptyView: some View {
|
||||
Text(LocalText.highlightCardNoHighlightsOnPage)
|
||||
|
|
@ -134,6 +137,9 @@
|
|||
highlightID: highlightParams.highlightID,
|
||||
dataService: dataService
|
||||
)
|
||||
if let onDeleteHighlight = onDeleteHighlight {
|
||||
onDeleteHighlight(highlightParams.highlightID)
|
||||
}
|
||||
},
|
||||
onSetLabels: { highlightID in
|
||||
setLabelsHighlight = Highlight.lookup(byID: highlightID, inContext: dataService.viewContext)
|
||||
|
|
|
|||
|
|
@ -74,26 +74,26 @@ struct GridCardNavigationLink: View {
|
|||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
func tapAction() {
|
||||
scale = 0.95
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
|
||||
scale = 1.0
|
||||
viewModel.selectedItem = item
|
||||
viewModel.linkIsActive = true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
EmptyView()
|
||||
}
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler, tapAction: {
|
||||
withAnimation { tapAction() }
|
||||
})
|
||||
Button {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
} else {
|
||||
viewModel.selectedItem = item
|
||||
viewModel.linkIsActive = true
|
||||
}
|
||||
} label: {
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
GridCard(item: item, isContextMenuOpen: $isContextMenuOpen, actionHandler: actionHandler)
|
||||
}
|
||||
}
|
||||
.aspectRatio(1.8, contentMode: .fill)
|
||||
.scaleEffect(scale)
|
||||
|
|
@ -102,10 +102,6 @@ struct GridCardNavigationLink: View {
|
|||
.onTapGesture {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
} else {
|
||||
withAnimation {
|
||||
tapAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ struct LibraryFeatureCardNavigationLink: View {
|
|||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
let item: LinkedItem
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
||||
@State var showFeatureActions = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Button {
|
||||
showFeatureActions = false
|
||||
viewModel.selectedItem = item
|
||||
viewModel.linkIsActive = true
|
||||
} label: {
|
||||
|
|
@ -29,11 +31,38 @@ struct LibraryFeatureCardNavigationLink: View {
|
|||
}
|
||||
.opacity(0)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onAppear {
|
||||
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
|
||||
}
|
||||
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
|
||||
}
|
||||
.confirmationDialog("", isPresented: $showFeatureActions) {
|
||||
if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .pinned {
|
||||
Button("Unpin", action: {
|
||||
viewModel.unpinItem(dataService: dataService, item: item)
|
||||
})
|
||||
}
|
||||
Button("Pin", action: {
|
||||
viewModel.pinItem(dataService: dataService, item: item)
|
||||
})
|
||||
Button("Archive", action: {
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true)
|
||||
})
|
||||
Button("Remove", action: {
|
||||
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
})
|
||||
if FeaturedItemFilter(rawValue: viewModel.featureFilter) != .pinned {
|
||||
Button("Mark Read", action: {
|
||||
viewModel.markRead(dataService: dataService, item: item)
|
||||
})
|
||||
Button("Mark Unread", action: {
|
||||
viewModel.markUnread(dataService: dataService, item: item)
|
||||
})
|
||||
}
|
||||
Button("Dismiss", role: .cancel, action: {
|
||||
showFeatureActions = false
|
||||
})
|
||||
}
|
||||
.delayedGesture(LongPressGesture().onEnded { _ in
|
||||
showFeatureActions = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
|
||||
import Introspect
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Views
|
||||
|
||||
@MainActor final class FilterSelectorViewModel: NSObject, ObservableObject {
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String = ""
|
||||
@Published var showErrorMessage: Bool = false
|
||||
|
||||
func error(_ msg: String) {
|
||||
errorMessage = msg
|
||||
showErrorMessage = true
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
struct FilterSelectorView: View {
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
@ObservedObject var filterViewModel = FilterByLabelsViewModel()
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var showLabelsSheet = false
|
||||
|
||||
init(viewModel: HomeFeedViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
#elseif os(macOS)
|
||||
List {
|
||||
innerBody
|
||||
}
|
||||
.listStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
.navigationBarTitle("Library")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing: doneButton)
|
||||
}
|
||||
|
||||
private var innerBody: some View {
|
||||
Group {
|
||||
Section {
|
||||
ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
|
||||
HStack {
|
||||
Text(filter.displayName)
|
||||
.foregroundColor(viewModel.appliedFilter == filter.rawValue ? Color.blue : Color.appTextDefault)
|
||||
Spacer()
|
||||
if viewModel.appliedFilter == filter.rawValue {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(Color.blue)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
viewModel.appliedFilter = filter.rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Labels") {
|
||||
Button(
|
||||
action: {
|
||||
showLabelsSheet = true
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
Text("Select Labels (\(viewModel.selectedLabels.count))")
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLabelsSheet) {
|
||||
FilterByLabelsView(
|
||||
initiallySelected: viewModel.selectedLabels,
|
||||
initiallyNegated: viewModel.negatedLabels
|
||||
) {
|
||||
self.viewModel.selectedLabels = $0
|
||||
self.viewModel.negatedLabels = $1
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await filterViewModel.loadLabels(
|
||||
dataService: dataService,
|
||||
initiallySelectedLabels: viewModel.selectedLabels,
|
||||
initiallyNegatedLabels: viewModel.negatedLabels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func isNegated(_ label: LinkedItemLabel) -> Bool {
|
||||
filterViewModel.negatedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
func isSelected(_ label: LinkedItemLabel) -> Bool {
|
||||
filterViewModel.selectedLabels.contains(where: { $0.id == label.id })
|
||||
}
|
||||
|
||||
var doneButton: some View {
|
||||
Button(
|
||||
action: { dismiss() },
|
||||
label: { Text("Done") }
|
||||
)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
struct HighlightsListView: View {
|
||||
var body: some View {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ extension LinkedItemFilter {
|
|||
return LocalText.allGeneric
|
||||
case .archived:
|
||||
return LocalText.archivedGeneric
|
||||
case .deleted:
|
||||
return "Deleted"
|
||||
case .hasHighlights:
|
||||
return LocalText.highlightedGeneric
|
||||
case .files:
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@State var searchPresented = false
|
||||
@State var addLinkPresented = false
|
||||
@State var settingsPresented = false
|
||||
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
|
|
@ -83,31 +84,25 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
.sheet(item: $viewModel.itemForHighlightsView) { item in
|
||||
NotebookView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showCommunityModal) {
|
||||
CommunityModal()
|
||||
.onAppear {
|
||||
shouldPromptCommunityModal = false
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showFiltersModal) {
|
||||
NavigationView {
|
||||
FilterSelectorView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barLeading) {
|
||||
Button(action: {
|
||||
viewModel.showCommunityModal = true
|
||||
}, label: {
|
||||
Image.smallOmnivoreLogo
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.overlay(alignment: .topTrailing, content: {
|
||||
if shouldPromptCommunityModal {
|
||||
Circle()
|
||||
.fill(Color.red)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
})
|
||||
})
|
||||
// Button(action: {
|
||||
// viewModel.showFiltersModal = true
|
||||
// }, label: {
|
||||
HStack(alignment: .center) {
|
||||
let title = (LinkedItemFilter(rawValue: viewModel.appliedFilter) ?? LinkedItemFilter.inbox).displayName
|
||||
Text(title)
|
||||
.font(Font.system(size: 18, weight: .semibold))
|
||||
// Image(systemName: "chevron.down")
|
||||
// .font(Font.system(size: 13, weight: .regular))
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// })
|
||||
}
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
|
|
@ -197,21 +192,21 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
.formSheet(isPresented: $viewModel.snoozePresented) {
|
||||
SnoozeView(
|
||||
snoozePresented: $viewModel.snoozePresented,
|
||||
itemToSnoozeID: $viewModel.itemToSnoozeID
|
||||
) { snoozeParams in
|
||||
Task {
|
||||
await viewModel.snoozeUntil(
|
||||
dataService: dataService,
|
||||
linkId: snoozeParams.feedItemId,
|
||||
until: snoozeParams.snoozeUntilDate,
|
||||
successMessage: snoozeParams.successMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// .formSheet(isPresented: $viewModel.snoozePresented) {
|
||||
// SnoozeView(
|
||||
// snoozePresented: $viewModel.snoozePresented,
|
||||
// itemToSnoozeID: $viewModel.itemToSnoozeID
|
||||
// ) { snoozeParams in
|
||||
// Task {
|
||||
// await viewModel.snoozeUntil(
|
||||
// dataService: dataService,
|
||||
// linkId: snoozeParams.feedItemId,
|
||||
// until: snoozeParams.snoozeUntilDate,
|
||||
// successMessage: snoozeParams.successMessage
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
.fullScreenCover(isPresented: $searchPresented) {
|
||||
LibrarySearchView(homeFeedViewModel: self.viewModel)
|
||||
}
|
||||
|
|
@ -262,9 +257,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@Binding var prefersListLayout: Bool
|
||||
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
@State private var showHideFeatureAlert = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
|
@ -325,210 +317,212 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
|
||||
func menuItems(for item: LinkedItem) -> some View {
|
||||
Group {
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
})
|
||||
Button("Remove Item", role: .destructive) {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
}
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text(LocalText.genericSnooze) } icon: { Image.moon }
|
||||
}
|
||||
}
|
||||
if let author = item.author {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.searchTerm = "author:\"\(author)\""
|
||||
},
|
||||
label: {
|
||||
Label(String("More by \(author)"), systemImage: "person")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
|
||||
}
|
||||
|
||||
var featureCard: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Menu(content: {
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(.continueReading)
|
||||
}, label: {
|
||||
Text("Continue Reading")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(.pinned)
|
||||
}, label: {
|
||||
Text("Pinned")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(.newsletters)
|
||||
}, label: {
|
||||
Text("Newsletters")
|
||||
})
|
||||
Button(action: {
|
||||
showHideFeatureAlert = true
|
||||
}, label: {
|
||||
Text("Hide this Section")
|
||||
})
|
||||
}, label: {
|
||||
HStack(alignment: .center) {
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).title.uppercased())
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
Image(systemName: "chevron.down")
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
})
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 0)
|
||||
|
||||
GeometryReader { geo in
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
if viewModel.featureItems.count > 0 {
|
||||
LazyHStack(alignment: .top, spacing: 10) {
|
||||
ForEach(viewModel.featureItems) { item in
|
||||
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
|
||||
VStack(spacing: 0) {
|
||||
if Color.isDarkMode {
|
||||
Color(hex: "#3D3D3D").frame(maxWidth: .infinity, maxHeight: 0.5)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
HStack {
|
||||
Menu(content: {
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
|
||||
}, label: {
|
||||
Text("Continue Reading")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}, label: {
|
||||
Text("Pinned")
|
||||
})
|
||||
Button(action: {
|
||||
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
|
||||
}, label: {
|
||||
Text("Newsletters")
|
||||
})
|
||||
Button(action: {
|
||||
showHideFeatureAlert = true
|
||||
}, label: {
|
||||
Text("Hide this Section")
|
||||
})
|
||||
}, label: {
|
||||
Group {
|
||||
HStack(alignment: .center) {
|
||||
Image(systemName: "line.3.horizontal.decrease")
|
||||
.font(Font.system(size: 13, weight: .regular))
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).title)
|
||||
.font(Font.system(size: 13, weight: .medium))
|
||||
}
|
||||
.tint(Color(hex: "#007AFF"))
|
||||
.padding(.vertical, 5)
|
||||
.padding(.horizontal, 7)
|
||||
.background(Color(hex: "#007AFF")?.opacity(0.1))
|
||||
.cornerRadius(5)
|
||||
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||
})
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, 10)
|
||||
.padding(.horizontal, 15)
|
||||
|
||||
GeometryReader { geo in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
if viewModel.featureItems.count > 0 {
|
||||
HStack(alignment: .top, spacing: 15) {
|
||||
Spacer(minLength: 1).frame(width: 1)
|
||||
ForEach(viewModel.featureItems) { item in
|
||||
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
|
||||
}
|
||||
Spacer(minLength: 1).frame(width: 1)
|
||||
}
|
||||
.padding(.top, 0)
|
||||
} else {
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).emptyMessage)
|
||||
.padding(.horizontal, UIDevice.isIPad ? 20 : 10)
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
.frame(maxWidth: geo.size.width)
|
||||
.frame(height: 60, alignment: .topLeading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else {
|
||||
Text((FeaturedItemFilter(rawValue: viewModel.featureFilter) ?? .continueReading).emptyMessage)
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
.foregroundColor(Color(hex: "#898989"))
|
||||
.frame(maxWidth: geo.size.width)
|
||||
.frame(height: 60, alignment: .topLeading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color.isDarkMode ? Color(hex: "#1C1C1C") : Color.systemBackground)
|
||||
.frame(height: 190)
|
||||
|
||||
Text((LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Inbox").uppercased())
|
||||
.font(Font.system(size: 14, weight: .regular))
|
||||
if !Color.isDarkMode {
|
||||
VStack {
|
||||
LinearGradient(gradient: Gradient(colors: [.black.opacity(0.06), .systemGray6]),
|
||||
startPoint: .top, endPoint: .bottom)
|
||||
.frame(maxWidth: .infinity, maxHeight: 6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.background(Color.systemGray6)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
VStack {
|
||||
Color(hex: "#3D3D3D").frame(maxWidth: .infinity, maxHeight: 0.5)
|
||||
Spacer()
|
||||
}
|
||||
.background(Color.systemBackground)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationLink(
|
||||
destination: LinkDestination(selectedItem: viewModel.selectedItem),
|
||||
isActive: $viewModel.linkIsActive
|
||||
) {
|
||||
EmptyView()
|
||||
let horizontalInset = CGFloat(UIDevice.isIPad ? 20 : 10)
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.showLoadingBar {
|
||||
ShimmeringLoader()
|
||||
} else {
|
||||
Spacer(minLength: 2)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.showLoadingBar {
|
||||
ShimmeringLoader()
|
||||
|
||||
List {
|
||||
filtersHeader
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset))
|
||||
|
||||
if viewModel.listConfig.hasFeatureCards,
|
||||
!viewModel.hideFeatureSection,
|
||||
viewModel.items.count > 0,
|
||||
viewModel.searchTerm.isEmpty,
|
||||
viewModel.selectedLabels.isEmpty,
|
||||
viewModel.negatedLabels.isEmpty,
|
||||
LinkedItemFilter(rawValue: viewModel.appliedFilter) == .inbox
|
||||
{
|
||||
featureCard
|
||||
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowSeparator(.hidden, edges: .all)
|
||||
.modifier(AnimatingCellHeight(height: 190 + (Color.isDarkMode ? 13 : 13)))
|
||||
}
|
||||
|
||||
ForEach(viewModel.items) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
.listRowInsets(.init(top: 0, leading: horizontalInset, bottom: 10, trailing: horizontalInset))
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.leadingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
ForEach(viewModel.listConfig.trailingSwipeActions, id: \.self) { action in
|
||||
swipeActionButton(action: action, item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(0)
|
||||
.listStyle(PlainListStyle())
|
||||
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFeatureAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
viewModel.hideFeatureSection = true
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
|
||||
}
|
||||
}
|
||||
|
||||
func swipeActionButton(action: SwipeAction, item: LinkedItem) -> AnyView {
|
||||
switch action {
|
||||
case .pin:
|
||||
let isPinned = item.labels?.allObjects.first { ($0 as? LinkedItemLabel)?.name == "Pinned" } != nil
|
||||
return AnyView(Button(action: {
|
||||
if isPinned {
|
||||
viewModel.unpinItem(dataService: dataService, item: item)
|
||||
} else {
|
||||
Spacer(minLength: 2)
|
||||
viewModel.pinItem(dataService: dataService, item: item)
|
||||
}
|
||||
}, label: {
|
||||
VStack {
|
||||
Image.pinRotated
|
||||
Text(isPinned ? "Unpin" : "Pin")
|
||||
}
|
||||
}).tint(Color(hex: "#0A84FF")))
|
||||
case .archive:
|
||||
return AnyView(Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
|
||||
}
|
||||
}, label: {
|
||||
Label(!item.isArchived ? "Archive" : "Unarchive",
|
||||
systemImage: !item.isArchived ? "archivebox" : "tray.and.arrow.down.fill")
|
||||
})
|
||||
.tint(!item.isArchived ? .green : .indigo))
|
||||
case .delete:
|
||||
return AnyView(Button(
|
||||
action: {
|
||||
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
},
|
||||
label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
).tint(.red))
|
||||
case .moveToInbox:
|
||||
return AnyView(Button(
|
||||
action: {
|
||||
// viewModel.addLabel(dataService: dataService, item: item, label: "Inbox", color)
|
||||
|
||||
List {
|
||||
filtersHeader
|
||||
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
|
||||
|
||||
if !viewModel.hideFeatureSection, viewModel.items.count > 0, viewModel.searchTerm.isEmpty, viewModel.selectedLabels.isEmpty, viewModel.negatedLabels.isEmpty {
|
||||
featureCard
|
||||
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
|
||||
.modifier(AnimatingCellHeight(height: viewModel.featureItems.count > 0 ? 260 : 130))
|
||||
}
|
||||
|
||||
ForEach(viewModel.items) { item in
|
||||
FeedCardNavigationLink(
|
||||
item: item,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.listRowSeparatorTint(Color.thBorderColor)
|
||||
.listRowInsets(.init(top: 0, leading: 10, bottom: 10, trailing: 10))
|
||||
.contextMenu {
|
||||
menuItems(for: item)
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !item.isArchived {
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true)
|
||||
}
|
||||
}, label: {
|
||||
Label("Archive", systemImage: "archivebox")
|
||||
}).tint(.green)
|
||||
} else {
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: false)
|
||||
}
|
||||
}, label: {
|
||||
Label("Unarchive", systemImage: "tray.and.arrow.down.fill")
|
||||
}).tint(.indigo)
|
||||
}
|
||||
Button(
|
||||
action: {
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
).tint(.red)
|
||||
}
|
||||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
viewModel.itemToSnoozeID = item.id
|
||||
viewModel.snoozePresented = true
|
||||
} label: {
|
||||
Label { Text(LocalText.genericSnooze) } icon: { Image.moon }
|
||||
}.tint(.appYellow48)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Label("Move to Inbox", systemImage: "tray.fill")
|
||||
}
|
||||
.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) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
|
||||
}
|
||||
}
|
||||
self.itemToRemove = nil
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
}
|
||||
.alert("The Feature Section will be removed from your library. You can add it back from the filter settings in your profile.",
|
||||
isPresented: $showHideFeatureAlert) {
|
||||
Button("OK", role: .destructive) {
|
||||
viewModel.hideFeatureSection = true
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
|
||||
}
|
||||
).tint(Color(hex: "#0A84FF")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -537,8 +531,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@State private var itemToRemove: LinkedItem?
|
||||
@State private var confirmationShown = false
|
||||
@State var isContextMenuOpen = false
|
||||
|
||||
@ObservedObject var viewModel: HomeFeedViewModel
|
||||
|
|
@ -550,8 +542,7 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
case .toggleArchiveStatus:
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
|
||||
case .delete:
|
||||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
case .editLabels:
|
||||
viewModel.itemUnderLabelEdit = item
|
||||
case .editTitle:
|
||||
|
|
@ -563,17 +554,69 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
Task { await viewModel.loadItems(dataService: dataService, isRefresh: isRefresh) }
|
||||
}
|
||||
|
||||
var filtersHeader: some View {
|
||||
GeometryReader { reader in
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
if viewModel.searchTerm.count > 0 {
|
||||
TextChipButton.makeSearchFilterButton(title: viewModel.searchTerm) {
|
||||
viewModel.searchTerm = ""
|
||||
}.frame(maxWidth: reader.size.width * 0.66)
|
||||
} else {
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemFilter.allCases, id: \.self) { filter in
|
||||
Button(filter.displayName, action: { viewModel.appliedFilter = filter.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemFilter(rawValue: viewModel.appliedFilter)?.displayName ?? "Filter"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Menu(
|
||||
content: {
|
||||
ForEach(LinkedItemSort.allCases, id: \.self) { sort in
|
||||
Button(sort.displayName, action: { viewModel.appliedSort = sort.rawValue })
|
||||
}
|
||||
},
|
||||
label: {
|
||||
TextChipButton.makeMenuButton(
|
||||
title: LinkedItemSort(rawValue: viewModel.appliedSort)?.displayName ?? "Sort"
|
||||
)
|
||||
}
|
||||
)
|
||||
TextChipButton.makeAddLabelButton {
|
||||
viewModel.showLabelsSheet = true
|
||||
}
|
||||
ForEach(viewModel.selectedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: false) {
|
||||
viewModel.selectedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.negatedLabels, id: \.self) { label in
|
||||
TextChipButton.makeRemovableLabelButton(feedItemLabel: label, negated: true) {
|
||||
viewModel.negatedLabels.removeAll { $0.id == label.id }
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(0)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScrollView {
|
||||
NavigationLink(
|
||||
destination: LinkDestination(selectedItem: viewModel.selectedItem),
|
||||
isActive: $viewModel.linkIsActive
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
filtersHeader
|
||||
.padding(.leading, 16)
|
||||
.padding(.bottom, 25)
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 16)], spacing: 16) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 325), spacing: 16)], alignment: .leading, spacing: 16) {
|
||||
ForEach(viewModel.items) { item in
|
||||
GridCardNavigationLink(
|
||||
item: item,
|
||||
|
|
@ -581,8 +624,13 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
isContextMenuOpen: $isContextMenuOpen,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
libraryItemMenu(dataService: dataService, viewModel: viewModel, item: item)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding()
|
||||
.background(
|
||||
GeometryReader {
|
||||
|
|
@ -605,18 +653,6 @@ struct AnimatingCellHeight: AnimatableModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
// swiftlint:disable:next line_length
|
||||
.alert("Are you sure you want to delete this item? All associated notes and highlights will be deleted.", isPresented: $confirmationShown) {
|
||||
Button("Delete Item", role: .destructive) {
|
||||
if let itemToRemove = itemToRemove {
|
||||
withAnimation {
|
||||
viewModel.removeLink(dataService: dataService, objectID: itemToRemove.objectID)
|
||||
}
|
||||
}
|
||||
self.itemToRemove = nil
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel) { self.itemToRemove = nil }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ import Views
|
|||
itemToRemove = item
|
||||
confirmationShown = true
|
||||
},
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
label: { Label("Remove", systemImage: "trash") }
|
||||
)
|
||||
if FeatureFlag.enableSnooze {
|
||||
Button {
|
||||
|
|
@ -93,7 +93,7 @@ import Views
|
|||
}
|
||||
}
|
||||
.listStyle(InsetListStyle())
|
||||
.navigationTitle("Home")
|
||||
.navigationTitle("Library")
|
||||
.searchable(
|
||||
text: $viewModel.searchTerm,
|
||||
placement: .toolbar
|
||||
|
|
|
|||
|
|
@ -31,9 +31,12 @@ import Views
|
|||
@Published var linkIsActive = false
|
||||
|
||||
@Published var showLabelsSheet = false
|
||||
@Published var showFiltersModal = false
|
||||
@Published var showCommunityModal = false
|
||||
@Published var featureItems = [LinkedItem]()
|
||||
|
||||
@Published var listConfig: LibraryListConfig
|
||||
|
||||
var cursor: String?
|
||||
|
||||
// These are used to make sure we handle search result
|
||||
|
|
@ -47,20 +50,27 @@ import Views
|
|||
@AppStorage(UserDefaultKey.lastSelectedLinkedItemFilter.rawValue) var appliedFilter = LinkedItemFilter.inbox.rawValue
|
||||
@AppStorage(UserDefaultKey.lastSelectedFeaturedItemFilter.rawValue) var featureFilter = FeaturedItemFilter.continueReading.rawValue
|
||||
|
||||
func setItems(_ items: [LinkedItem]) {
|
||||
self.items = items
|
||||
updateFeatureFilter(FeaturedItemFilter(rawValue: featureFilter))
|
||||
init(listConfig: LibraryListConfig) {
|
||||
self.listConfig = listConfig
|
||||
super.init()
|
||||
}
|
||||
|
||||
func updateFeatureFilter(_ filter: FeaturedItemFilter?) {
|
||||
func setItems(_ context: NSManagedObjectContext, _ items: [LinkedItem]) {
|
||||
self.items = items
|
||||
updateFeatureFilter(context: context, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
}
|
||||
|
||||
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
|
||||
if let filter = filter {
|
||||
// now try to update the continue reading items:
|
||||
featureItems = (items.filter { item in
|
||||
filter.predicate.evaluate(with: item)
|
||||
} as NSArray)
|
||||
.sortedArray(using: [filter.sortDescriptor])
|
||||
.compactMap { $0 as? LinkedItem }
|
||||
featureFilter = filter.rawValue
|
||||
Task {
|
||||
featureFilter = filter.rawValue
|
||||
|
||||
featureItems = await loadFeatureItems(
|
||||
context: context,
|
||||
predicate: filter.predicate,
|
||||
sort: filter.sortDescriptor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
featureItems = []
|
||||
}
|
||||
|
|
@ -173,6 +183,8 @@ import Views
|
|||
cursor: isRefresh ? nil : cursor
|
||||
)
|
||||
|
||||
let filter = LinkedItemFilter(rawValue: appliedFilter)
|
||||
|
||||
if let queryResult = queryResult {
|
||||
let newItems: [LinkedItem] = {
|
||||
var itemObjects = [LinkedItem]()
|
||||
|
|
@ -182,15 +194,15 @@ import Views
|
|||
return itemObjects
|
||||
}()
|
||||
|
||||
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty {
|
||||
if searchTerm.replacingOccurrences(of: " ", with: "").isEmpty, filter?.allowLocalFetch ?? false {
|
||||
updateFetchController(dataService: dataService)
|
||||
} else {
|
||||
// Don't use FRC for searching. Use server results directly.
|
||||
if fetchedResultsController != nil {
|
||||
fetchedResultsController = nil
|
||||
setItems([])
|
||||
setItems(dataService.viewContext, [])
|
||||
}
|
||||
setItems(isRefresh ? newItems : items + newItems)
|
||||
setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
|
@ -223,6 +235,8 @@ import Views
|
|||
updateFetchController(dataService: dataService)
|
||||
}
|
||||
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
|
||||
isLoading = false
|
||||
showLoadingBar = false
|
||||
}
|
||||
|
|
@ -237,6 +251,15 @@ import Views
|
|||
showLoadingBar = false
|
||||
}
|
||||
|
||||
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [LinkedItem] {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
fetchRequest.fetchLimit = 25
|
||||
fetchRequest.predicate = predicate
|
||||
fetchRequest.sortDescriptors = [sort]
|
||||
|
||||
return (try? context.fetch(fetchRequest)) ?? []
|
||||
}
|
||||
|
||||
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
|
||||
|
||||
|
|
@ -288,7 +311,7 @@ import Views
|
|||
|
||||
fetchedResultsController.delegate = self
|
||||
try? fetchedResultsController.performFetch()
|
||||
setItems(fetchedResultsController.fetchedObjects ?? [])
|
||||
setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
|
||||
}
|
||||
|
||||
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
|
||||
|
|
@ -297,8 +320,68 @@ import Views
|
|||
}
|
||||
|
||||
func removeLink(dataService: DataService, objectID: NSManagedObjectID) {
|
||||
Snackbar.show(message: "Link removed")
|
||||
dataService.removeLink(objectID: objectID)
|
||||
removeLibraryItemAction(dataService: dataService, objectID: objectID)
|
||||
}
|
||||
|
||||
func recoverItem(dataService: DataService, itemID: String) {
|
||||
Task {
|
||||
if await dataService.recoverItem(itemID: itemID) {
|
||||
Snackbar.show(message: "Item recovered")
|
||||
} else {
|
||||
Snackbar.show(message: "Error. Check trash to recover.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getOrCreateLabel(dataService: DataService, named: String, color: String) -> LinkedItemLabel? {
|
||||
if let label = LinkedItemLabel.named(named, inContext: dataService.viewContext) {
|
||||
return label
|
||||
}
|
||||
if let labelID = try? dataService.createLabel(name: named, color: color, description: "") {
|
||||
return dataService.viewContext.object(with: labelID) as? LinkedItemLabel
|
||||
// return LinkedItemLabel.lookup(byID: labelID, inContext: dataService.viewContext)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func addLabel(dataService: DataService, item: LinkedItem, label: String, color: String) {
|
||||
if let label = getOrCreateLabel(dataService: dataService, named: "Pinned", color: color) {
|
||||
let existingLabels = item.labels?.allObjects.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
|
||||
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: existingLabels + [label.unwrappedID])
|
||||
|
||||
item.update(inContext: dataService.viewContext)
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
|
||||
}
|
||||
}
|
||||
|
||||
func removeLabel(dataService: DataService, item: LinkedItem, named: String) {
|
||||
let labelIds = item.labels?
|
||||
.filter { ($0 as? LinkedItemLabel)?.name != named }
|
||||
.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
|
||||
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: labelIds)
|
||||
item.update(inContext: dataService.viewContext)
|
||||
}
|
||||
|
||||
func pinItem(dataService: DataService, item: LinkedItem) {
|
||||
addLabel(dataService: dataService, item: item, label: "Pinned", color: "#0A84FF")
|
||||
if featureFilter == FeaturedItemFilter.pinned.rawValue {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func unpinItem(dataService: DataService, item: LinkedItem) {
|
||||
removeLabel(dataService: dataService, item: item, named: "Pinned")
|
||||
if featureFilter == FeaturedItemFilter.pinned.rawValue {
|
||||
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(dataService: DataService, item: LinkedItem) {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 100, anchorIndex: 0)
|
||||
}
|
||||
|
||||
func markUnread(dataService: DataService, item: LinkedItem) {
|
||||
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
|
||||
}
|
||||
|
||||
func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) async {
|
||||
|
|
@ -362,6 +445,6 @@ import Views
|
|||
|
||||
extension HomeFeedViewModel: NSFetchedResultsControllerDelegate {
|
||||
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
|
||||
setItems(controller.fetchedObjects as? [LinkedItem] ?? [])
|
||||
setItems(controller.managedObjectContext, controller.fetchedObjects as? [LinkedItem] ?? [])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ import Utils
|
|||
import Views
|
||||
|
||||
struct HomeView: View {
|
||||
@StateObject private var viewModel = HomeFeedViewModel()
|
||||
@State private var viewModel: HomeFeedViewModel
|
||||
|
||||
init(viewModel: HomeFeedViewModel) {
|
||||
self.viewModel = viewModel
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var navView: some View {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import Utils
|
||||
import Views
|
||||
|
||||
@MainActor func libraryItemMenu(dataService: DataService, viewModel: HomeFeedViewModel, item: LinkedItem) -> some View {
|
||||
Group {
|
||||
if item.state != "DELETED" {
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderLabelEdit = item },
|
||||
label: { Label(item.labels?.count == 0 ? "Add Labels" : "Edit Labels", systemImage: "tag") }
|
||||
)
|
||||
Button(action: {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
viewModel.setLinkArchived(
|
||||
dataService: dataService,
|
||||
objectID: item.objectID,
|
||||
archived: !item.isArchived
|
||||
)
|
||||
}
|
||||
}, label: {
|
||||
Label(
|
||||
item.isArchived ? "Unarchive" : "Archive",
|
||||
systemImage: item.isArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
})
|
||||
Button("Remove Item", role: .destructive) {
|
||||
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
|
||||
}
|
||||
if let author = item.author {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.searchTerm = "author:\"\(author)\""
|
||||
},
|
||||
label: {
|
||||
Label(String("More by \(author)"), systemImage: "person")
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Button(
|
||||
action: { viewModel.recoverItem(dataService: dataService, itemID: item.unwrappedID) },
|
||||
label: { Label("Recover", systemImage: "trash.slash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 7/1/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CardStyle {
|
||||
case library
|
||||
case highlights
|
||||
}
|
||||
|
||||
struct LibraryListConfig {
|
||||
var hasFeatureCards = false
|
||||
var leadingSwipeActions = [SwipeAction]()
|
||||
var trailingSwipeActions = [SwipeAction]()
|
||||
var cardStyle = CardStyle.library
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/29/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftUI
|
||||
|
||||
struct LibraryListView: View {
|
||||
@StateObject private var subViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var libraryViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var highlightsViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .highlights
|
||||
)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationLink(
|
||||
destination: LinkDestination(selectedItem: libraryViewModel.selectedItem),
|
||||
isActive: $libraryViewModel.linkIsActive
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
HomeView(viewModel: libraryViewModel)
|
||||
.tabItem {
|
||||
Label {
|
||||
Text("Library")
|
||||
} icon: {
|
||||
Image.tabLibrary
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
18
apple/OmnivoreKit/Sources/App/Views/Home/SwipeAction.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// SwipeAction.swift
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 7/1/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
||||
enum SwipeAction: String, CaseIterable {
|
||||
case pin
|
||||
case archive
|
||||
case delete
|
||||
case moveToInbox
|
||||
}
|
||||
|
|
@ -1,8 +1,87 @@
|
|||
//
|
||||
// File.swift
|
||||
//
|
||||
//
|
||||
//
|
||||
// Created by Jackson Harper on 6/29/23.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
||||
struct LibraryTabView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
|
||||
@StateObject private var subViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: false,
|
||||
leadingSwipeActions: [.moveToInbox],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var libraryViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .library
|
||||
)
|
||||
)
|
||||
|
||||
@StateObject private var highlightsViewModel = HomeFeedViewModel(
|
||||
listConfig: LibraryListConfig(
|
||||
hasFeatureCards: true,
|
||||
leadingSwipeActions: [.pin],
|
||||
trailingSwipeActions: [.archive, .delete],
|
||||
cardStyle: .highlights
|
||||
)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
NavigationLink(
|
||||
destination: LinkDestination(selectedItem: libraryViewModel.selectedItem),
|
||||
isActive: $libraryViewModel.linkIsActive
|
||||
) {
|
||||
EmptyView()
|
||||
}
|
||||
// TabView(selection: $selection) {
|
||||
// BriefingView(
|
||||
// articleId: "98e017a3-79d5-4049-97bc-ff170153792a"
|
||||
// )
|
||||
// .tabItem {
|
||||
// Label {
|
||||
// Text("Your Briefing")
|
||||
// } icon: {
|
||||
// Image.tabBriefing.padding(.trailing, 5)
|
||||
// }
|
||||
// }.tag(0)
|
||||
|
||||
HomeView(viewModel: libraryViewModel)
|
||||
// .tabItem {
|
||||
// Label {
|
||||
// Text("Library")
|
||||
// } icon: {
|
||||
// Image.tabLibrary
|
||||
// }
|
||||
// }.tag(1)
|
||||
|
||||
// HomeView(viewModel: highlightsViewModel)
|
||||
// .tabItem {
|
||||
// Label {
|
||||
// Text("Highlights")
|
||||
// } icon: {
|
||||
// Image.tabHighlights
|
||||
// }
|
||||
// }.tag(2)
|
||||
// }
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,7 @@ import Views
|
|||
|
||||
func handleDeleteAction(dataService: DataService) {
|
||||
guard let objectID = item?.objectID ?? pdfItem?.objectID else { return }
|
||||
showInSnackbar("Link removed")
|
||||
dataService.removeLink(objectID: objectID)
|
||||
removeLibraryItemAction(dataService: dataService, objectID: objectID)
|
||||
}
|
||||
|
||||
func updateItemReadStatus(dataService: DataService) {
|
||||
|
|
@ -70,43 +69,17 @@ import Views
|
|||
struct LinkItemDetailView: View {
|
||||
@EnvironmentObject var authenticator: Authenticator
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
|
||||
|
||||
static let navBarHeight = 50.0
|
||||
let linkedItemObjectID: NSManagedObjectID
|
||||
let isPDF: Bool
|
||||
|
||||
@StateObject private var viewModel = LinkItemDetailViewModel()
|
||||
@State private var showFontSizePopover = false
|
||||
@State private var showTitleEdit = false
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
init(linkedItemObjectID: NSManagedObjectID, isPDF: Bool) {
|
||||
self.linkedItemObjectID = linkedItemObjectID
|
||||
self.isPDF = isPDF
|
||||
}
|
||||
|
||||
var toggleReadStatusToolbarItem: some View {
|
||||
Button(
|
||||
action: {
|
||||
viewModel.updateItemReadStatus(dataService: dataService)
|
||||
},
|
||||
label: {
|
||||
Image(systemName: viewModel.isItemRead ? "line.horizontal.3.decrease.circle" : "checkmark.circle")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var removeLinkToolbarItem: some View {
|
||||
Button(
|
||||
action: { print("delete item action") },
|
||||
label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack { // Using ZStack so .task can be used on if/else body
|
||||
if isPDF {
|
||||
|
|
@ -123,76 +96,6 @@ struct LinkItemDetailView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
var navBar: some View {
|
||||
HStack(alignment: .center) {
|
||||
Button(
|
||||
action: { self.presentationMode.wrappedValue.dismiss() },
|
||||
label: {
|
||||
Image(systemName: "chevron.backward")
|
||||
.font(.appNavbarIcon)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Spacer()
|
||||
Button(
|
||||
action: { showFontSizePopover.toggle() },
|
||||
label: {
|
||||
Image(systemName: "textformat.size")
|
||||
.font(.appTitleTwo)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: { showTitleEdit = true },
|
||||
label: { Label("Edit Info", systemImage: "info.circle") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.handleArchiveAction(dataService: dataService) },
|
||||
label: {
|
||||
Label(
|
||||
viewModel.isItemArchived ? "Unarchive" : "Archive",
|
||||
systemImage: viewModel.isItemArchived ? "tray.and.arrow.down.fill" : "archivebox"
|
||||
)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
action: { showDeleteConfirmation = true },
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.padding(.horizontal)
|
||||
.scaleEffect(navBarVisibilityRatio)
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(height: readerViewNavBarHeight * navBarVisibilityRatio)
|
||||
.opacity(navBarVisibilityRatio)
|
||||
.background(Color.systemBackground)
|
||||
.onTapGesture {
|
||||
showFontSizePopover = false
|
||||
}
|
||||
.alert("Are you sure?", isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Link", role: .destructive) {
|
||||
viewModel.handleDeleteAction(dataService: dataService)
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel, action: {})
|
||||
}
|
||||
.sheet(isPresented: $showTitleEdit) {
|
||||
if let item = viewModel.item {
|
||||
LinkedItemMetadataEditView(item: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var pdfContainerView: some View {
|
||||
if let pdfItem = viewModel.pdfItem, let pdfURL = pdfItem.pdfURL {
|
||||
#if os(iOS)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import Services
|
|||
import SwiftUI
|
||||
import Views
|
||||
|
||||
public struct PrimaryContentView: View {
|
||||
@MainActor public struct PrimaryContentView: View {
|
||||
let categories = [
|
||||
PrimaryContentCategory.feed,
|
||||
PrimaryContentCategory.profile
|
||||
|
|
@ -14,7 +14,8 @@ public struct PrimaryContentView: View {
|
|||
if UIDevice.isIPad {
|
||||
splitView
|
||||
} else {
|
||||
HomeView()
|
||||
// HomeView()
|
||||
LibraryTabView()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
splitView
|
||||
|
|
@ -51,7 +52,7 @@ public struct PrimaryContentView: View {
|
|||
#endif
|
||||
}
|
||||
|
||||
struct PrimaryContentSidebar: View {
|
||||
@MainActor struct PrimaryContentSidebar: View {
|
||||
@State private var selectedCategory: PrimaryContentCategory?
|
||||
let categories: [PrimaryContentCategory]
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
|
||||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import Services
|
||||
import Views
|
||||
|
||||
func removeLibraryItemAction(dataService: DataService, objectID: NSManagedObjectID) {
|
||||
dataService.viewContext.performAndWait {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? LinkedItem {
|
||||
item.state = "DELETED"
|
||||
try? dataService.viewContext.save()
|
||||
}
|
||||
}
|
||||
|
||||
let syncTask = Task.detached(priority: .background) {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
let canceled = Task.isCancelled
|
||||
if !canceled {
|
||||
print("syncing link deletion")
|
||||
dataService.removeLink(objectID: objectID, sync: true)
|
||||
}
|
||||
} catch {
|
||||
print("error running task: ", error)
|
||||
}
|
||||
print("checking if task is canceled: ", Task.isCancelled)
|
||||
}
|
||||
|
||||
Snackbar.show(message: "Item removed", undoAction: {
|
||||
print("canceling task", syncTask)
|
||||
syncTask.cancel()
|
||||
dataService.viewContext.performAndWait {
|
||||
if let item = dataService.viewContext.object(with: objectID) as? LinkedItem {
|
||||
item.state = "SUCCEEDED"
|
||||
try? dataService.viewContext.save()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -62,7 +62,7 @@ struct InnerRootView: View {
|
|||
}
|
||||
}
|
||||
#endif
|
||||
.snackBar(isShowing: $viewModel.showSnackbar, message: viewModel.snackbarMessage)
|
||||
.snackBar(isShowing: $viewModel.showSnackbar, operation: viewModel.snackbarOperation)
|
||||
// Schedule the dismissal every time we present the snackbar.
|
||||
.onChange(of: viewModel.showSnackbar) { newValue in
|
||||
if newValue {
|
||||
|
|
@ -93,14 +93,15 @@ struct InnerRootView: View {
|
|||
#if os(iOS)
|
||||
.onReceive(NSNotification.operationSuccessPublisher) { notification in
|
||||
if let message = notification.userInfo?["message"] as? String {
|
||||
viewModel.snackbarOperation = SnackbarOperation(message: message,
|
||||
undoAction: notification.userInfo?["undoAction"] as? SnackbarUndoAction)
|
||||
viewModel.showSnackbar = true
|
||||
viewModel.snackbarMessage = message
|
||||
}
|
||||
}
|
||||
.onReceive(NSNotification.operationFailedPublisher) { notification in
|
||||
if let message = notification.userInfo?["message"] as? String {
|
||||
viewModel.showSnackbar = true
|
||||
viewModel.snackbarMessage = message
|
||||
viewModel.snackbarOperation = SnackbarOperation(message: message, undoAction: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ public final class RootViewModel: ObservableObject {
|
|||
@Published public var showNewFeaturePrimer = false
|
||||
@AppStorage(UserDefaultKey.shouldShowNewFeaturePrimer.rawValue) var shouldShowNewFeaturePrimer = false
|
||||
|
||||
@Published var snackbarMessage: String?
|
||||
@Published var showMiniPlayer = false
|
||||
@Published var showSnackbar = false
|
||||
@Published var showMiniPlayer = true
|
||||
@Published var snackbarOperation: SnackbarOperation?
|
||||
|
||||
public init() {
|
||||
registerFonts()
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ struct WebReaderContainerView: View {
|
|||
@State private var hasPerformedHighlightMutations = false
|
||||
@State var showHighlightAnnotationModal = false
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var progressViewOpacity = 0.0
|
||||
@State var readerSettingsChangedTransactionID: UUID?
|
||||
@State var annotationSaveTransactionID: UUID?
|
||||
|
|
@ -32,6 +31,7 @@ struct WebReaderContainerView: View {
|
|||
@State private var showErrorAlertMessage = false
|
||||
@State private var showRecommendSheet = false
|
||||
@State private var lastScrollPercentage: Int?
|
||||
@State private var isRecovering = false
|
||||
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State var displayLinkSheet = false
|
||||
|
|
@ -250,7 +250,7 @@ struct WebReaderContainerView: View {
|
|||
)
|
||||
Button(
|
||||
action: delete,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
label: { Label("Remove", systemImage: "trash") }
|
||||
)
|
||||
Button(
|
||||
action: {
|
||||
|
|
@ -346,17 +346,6 @@ struct WebReaderContainerView: View {
|
|||
.opacity(navBarVisibilityRatio)
|
||||
.foregroundColor(ThemeManager.currentTheme.isDark ? .white : .black)
|
||||
.background(ThemeManager.currentBgColor)
|
||||
.alert("Are you sure you want to remove this item? All associated notes and highlights will be deleted.",
|
||||
isPresented: $showDeleteConfirmation) {
|
||||
Button("Remove Item", role: .destructive) {
|
||||
Snackbar.show(message: "Link removed")
|
||||
dataService.removeLink(objectID: item.objectID)
|
||||
#if os(iOS)
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
#endif
|
||||
}
|
||||
Button(LocalText.cancelGeneric, role: .cancel, action: {})
|
||||
}
|
||||
.sheet(isPresented: $showLabelsModal) {
|
||||
ApplyLabelsView(mode: .item(item), isSearchFocused: false, onSave: { labels in
|
||||
showLabelsModal = false
|
||||
|
|
@ -444,7 +433,7 @@ struct WebReaderContainerView: View {
|
|||
#if os(iOS)
|
||||
UIPasteboard.general.string = item.unwrappedPageURLString
|
||||
#else
|
||||
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
|
||||
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
|
||||
#endif
|
||||
showInSnackbar("Link Copied")
|
||||
}, label: { Text(LocalText.readerCopyLink) })
|
||||
|
|
@ -506,16 +495,39 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
} else if let errorMessage = viewModel.errorMessage {
|
||||
VStack {
|
||||
Text(errorMessage).padding()
|
||||
if viewModel.allowRetry, viewModel.hasOriginalUrl(item) {
|
||||
Button("Open Original", action: {
|
||||
openOriginalURL(urlString: item.pageURLString)
|
||||
}).buttonStyle(RoundedRectButtonStyle())
|
||||
if let urlStr = item.pageURLString, let username = dataService.currentViewer?.username, let url = URL(string: urlStr) {
|
||||
Button("Attempt to Save Again", action: {
|
||||
viewModel.errorMessage = nil
|
||||
viewModel.saveLinkAndFetch(dataService: dataService, username: username, url: url)
|
||||
if item.state == "DELETED" {
|
||||
Text("Item has been deleted, would you like to recover it?").padding()
|
||||
if isRecovering {
|
||||
ProgressView()
|
||||
} else {
|
||||
Button("Recover", action: {
|
||||
self.isRecovering = true
|
||||
Task {
|
||||
if !(await dataService.recoverItem(itemID: item.unwrappedID)) {
|
||||
Snackbar.show(message: "Error recovering item")
|
||||
} else {
|
||||
await viewModel.loadContent(
|
||||
dataService: dataService,
|
||||
username: dataService.currentViewer?.username ?? "me",
|
||||
itemID: item.unwrappedID
|
||||
)
|
||||
}
|
||||
isRecovering = false
|
||||
}
|
||||
}).buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
} else {
|
||||
Text(errorMessage).padding()
|
||||
Button("Open Original", action: {
|
||||
openOriginalURL(urlString: item.pageURLString)
|
||||
}).buttonStyle(RoundedRectButtonStyle())
|
||||
if let urlStr = item.pageURLString, let username = dataService.currentViewer?.username, let url = URL(string: urlStr) {
|
||||
Button("Attempt to Save Again", action: {
|
||||
viewModel.errorMessage = nil
|
||||
viewModel.saveLinkAndFetch(dataService: dataService, username: username, url: url)
|
||||
}).buttonStyle(RoundedRectButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -607,7 +619,12 @@ struct WebReaderContainerView: View {
|
|||
}
|
||||
|
||||
func delete() {
|
||||
showDeleteConfirmation = true
|
||||
removeLibraryItemAction(dataService: dataService, objectID: item.objectID)
|
||||
#if os(iOS)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func editLabels() {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ public enum ArticleContentStatus: String {
|
|||
case processing = "PROCESSING"
|
||||
case succeeded = "SUCCEEDED"
|
||||
case unknown = "UNKNOWN"
|
||||
case deleted = "DELETED"
|
||||
}
|
||||
|
||||
public struct ArticleContent {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ public enum LinkedItemFilter: String, CaseIterable {
|
|||
case recommended
|
||||
case all
|
||||
case archived
|
||||
case deleted
|
||||
case hasHighlights
|
||||
case files
|
||||
}
|
||||
|
|
@ -17,7 +18,7 @@ public extension LinkedItemFilter {
|
|||
case .inbox:
|
||||
return "in:inbox"
|
||||
case .readlater:
|
||||
return "in:inbox -label:Newsletter"
|
||||
return "in:library"
|
||||
case .newsletters:
|
||||
return "in:inbox label:Newsletter"
|
||||
case .recommended:
|
||||
|
|
@ -26,6 +27,8 @@ public extension LinkedItemFilter {
|
|||
return "in:all"
|
||||
case .archived:
|
||||
return "in:archive"
|
||||
case .deleted:
|
||||
return "in:trash"
|
||||
case .hasHighlights:
|
||||
return "has:highlights"
|
||||
case .files:
|
||||
|
|
@ -33,9 +36,20 @@ public extension LinkedItemFilter {
|
|||
}
|
||||
}
|
||||
|
||||
var allowLocalFetch: Bool {
|
||||
switch self {
|
||||
case .inbox:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var predicate: NSPredicate {
|
||||
let undeletedPredicate = NSPredicate(
|
||||
format: "%K != %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
format: "%K != %i AND %K != \"DELETED\"",
|
||||
#keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue),
|
||||
#keyPath(LinkedItem.state)
|
||||
)
|
||||
let notInArchivePredicate = NSPredicate(
|
||||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: false) as NSNumber
|
||||
|
|
@ -50,8 +64,11 @@ public extension LinkedItemFilter {
|
|||
let nonNewsletterLabelPredicate = NSPredicate(
|
||||
format: "NOT SUBQUERY(labels, $label, $label.name == \"Newsletter\") .@count > 0"
|
||||
)
|
||||
let nonRSSPredicate = NSPredicate(
|
||||
format: "NOT SUBQUERY(labels, $label, $label.name == \"RSS\") .@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate
|
||||
undeletedPredicate, notInArchivePredicate, nonNewsletterLabelPredicate, nonRSSPredicate
|
||||
])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
|
|
@ -73,6 +90,11 @@ public extension LinkedItemFilter {
|
|||
format: "%K == %@", #keyPath(LinkedItem.isArchived), Int(truncating: true) as NSNumber
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [undeletedPredicate, inArchivePredicate])
|
||||
case .deleted:
|
||||
let deletedPredicate = NSPredicate(
|
||||
format: "%K == %i", #keyPath(LinkedItem.serverSyncStatus), Int64(ServerSyncStatus.needsDeletion.rawValue)
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [deletedPredicate])
|
||||
case .files:
|
||||
// include pdf only
|
||||
let isPDFPredicate = NSPredicate(
|
||||
|
|
@ -102,8 +124,12 @@ public extension FeaturedItemFilter {
|
|||
switch self {
|
||||
case .continueReading:
|
||||
return "Continue Reading"
|
||||
default:
|
||||
return rawValue
|
||||
case .recommended:
|
||||
return "Recommended"
|
||||
case .newsletters:
|
||||
return "Newsletters"
|
||||
case .pinned:
|
||||
return "Pinned"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +138,7 @@ public extension FeaturedItemFilter {
|
|||
case .continueReading:
|
||||
return "Your recently read items will appear here."
|
||||
case .pinned:
|
||||
return "Create a label named Pinned and add it to items you'd like to appear here"
|
||||
return "Create a label named Pinned and add it to items you would like to appear here."
|
||||
case .recommended:
|
||||
return "Reads recommended in your Clubs will appear here."
|
||||
case .newsletters:
|
||||
|
|
@ -138,11 +164,11 @@ public extension FeaturedItemFilter {
|
|||
continueReadingPredicate, undeletedPredicate, notInArchivePredicate
|
||||
])
|
||||
case .pinned:
|
||||
let newsletterLabelPredicate = NSPredicate(
|
||||
let pinnedPredicate = NSPredicate(
|
||||
format: "SUBQUERY(labels, $label, $label.name == \"Pinned\").@count > 0"
|
||||
)
|
||||
return NSCompoundPredicate(andPredicateWithSubpredicates: [
|
||||
notInArchivePredicate, undeletedPredicate, newsletterLabelPredicate
|
||||
notInArchivePredicate, undeletedPredicate, pinnedPredicate
|
||||
])
|
||||
case .newsletters:
|
||||
// non-archived or deleted items with the Newsletter label
|
||||
|
|
@ -168,6 +194,8 @@ public extension FeaturedItemFilter {
|
|||
switch self {
|
||||
case .continueReading:
|
||||
return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
|
||||
case .pinned:
|
||||
return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false)
|
||||
default:
|
||||
return savedAtSort
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ extension DataService {
|
|||
let mutation = Selection.Mutation {
|
||||
try $0.createLabel(
|
||||
input: InputObjects.CreateLabelInput(
|
||||
color: label.color,
|
||||
color: OptionalArgument(label.color),
|
||||
description: OptionalArgument(label.labelDescription),
|
||||
name: label.name
|
||||
),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import Models
|
|||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func removeLink(objectID: NSManagedObjectID) {
|
||||
func removeLink(objectID: NSManagedObjectID, sync: Bool = true) {
|
||||
// First try to get the item synchronously, this is used later to delete files
|
||||
// Then we can async update core data and make the API call to sync the deletion
|
||||
|
||||
|
|
@ -28,8 +28,9 @@ public extension DataService {
|
|||
logger.debug("Failed to mark LinkedItem for deletion: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// Send update to server
|
||||
self.syncLinkDeletion(itemID: linkedItem.unwrappedID)
|
||||
if sync {
|
||||
self.syncLinkDeletion(itemID: linkedItem.unwrappedID)
|
||||
}
|
||||
}
|
||||
|
||||
if let linkedItemID = linkedItemID {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
import CoreData
|
||||
import Foundation
|
||||
import Models
|
||||
import SwiftGraphQL
|
||||
|
||||
public extension DataService {
|
||||
func recoverItem(itemID: String) async -> Bool {
|
||||
var itemUpdatedLocal = false
|
||||
// If the item is still available locally, update its state
|
||||
backgroundContext.performAndWait {
|
||||
if let linkedItem = LinkedItem.lookup(byID: itemID, inContext: backgroundContext) {
|
||||
linkedItem.serverSyncStatus = Int64(ServerSyncStatus.needsUpdate.rawValue)
|
||||
|
||||
do {
|
||||
try backgroundContext.save()
|
||||
itemUpdatedLocal = true
|
||||
logger.debug("LinkedItem updated succesfully")
|
||||
} catch {
|
||||
backgroundContext.rollback()
|
||||
logger.debug("Failed to update LinkedItem: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we recovered locally, but failed to sync the undelete, that is OK, because
|
||||
// the item shouldn't be deleted server side.
|
||||
return await syncServerRecoverItem(itemID: itemID) || itemUpdatedLocal
|
||||
}
|
||||
|
||||
func syncServerRecoverItem(itemID: String) async -> Bool {
|
||||
enum MutationResult {
|
||||
case saved(title: String)
|
||||
case error(errorMessage: String)
|
||||
}
|
||||
|
||||
let selection = Selection<MutationResult, Unions.UpdatePageResult> {
|
||||
try $0.on(
|
||||
updatePageError: .init { .error(errorMessage: try $0.errorCodes().first.toString()) },
|
||||
updatePageSuccess: .init {
|
||||
.saved(title: try $0.updatedPage(selection: Selection.Article { try $0.title() }))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
let mutation = Selection.Mutation {
|
||||
try $0.updatePage(
|
||||
input: .init(pageId: itemID,
|
||||
state: OptionalArgument(Enums.ArticleSavingRequestStatus.succeeded)),
|
||||
selection: selection
|
||||
)
|
||||
}
|
||||
|
||||
let path = appEnvironment.graphqlPath
|
||||
let headers = networker.defaultHeaders
|
||||
|
||||
try? await withCheckedThrowingContinuation { continuation in
|
||||
send(mutation, to: path, headers: headers) { _ in
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
let result = try? await loadLinkedItem(username: "me", itemID: itemID)
|
||||
return result != nil
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,9 @@ extension DataService {
|
|||
}
|
||||
}
|
||||
|
||||
linkedItem.update(inContext: self.backgroundContext)
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
// Send update to server
|
||||
self.syncLabelUpdates(itemID: itemID, labelIDs: labelIDs)
|
||||
}
|
||||
|
|
@ -40,7 +43,7 @@ extension DataService {
|
|||
let mutation = Selection.Mutation {
|
||||
try $0.setLabels(
|
||||
input: InputObjects.SetLabelsInput(
|
||||
labelIds: labelIDs,
|
||||
labelIds: OptionalArgument(labelIDs),
|
||||
pageId: itemID
|
||||
),
|
||||
selection: selection
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ public extension DataService {
|
|||
try $0.setLabelsForHighlight(
|
||||
input: InputObjects.SetLabelsForHighlightInput(
|
||||
highlightId: highlightID,
|
||||
labelIds: labelIDs
|
||||
labelIds: OptionalArgument(labelIDs)
|
||||
),
|
||||
selection: selection
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ public extension DataService {
|
|||
username: username,
|
||||
requestCount: requestCount + 1
|
||||
)
|
||||
case .succeeded, .unknown:
|
||||
case .succeeded, .unknown, .deleted:
|
||||
return fetchedContent
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ extension Sequence where Element == InternalLinkedItem {
|
|||
print("LinkedItems saved succesfully")
|
||||
} catch {
|
||||
context.rollback()
|
||||
print(error)
|
||||
print("Failed to save LinkedItems: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,9 +56,24 @@ public struct InternalLinkedItemLabel: Encodable {
|
|||
}
|
||||
}
|
||||
|
||||
extension LinkedItemLabel {
|
||||
public var unwrappedID: String { id ?? "" }
|
||||
public var unwrappedName: String { name ?? "" }
|
||||
public extension LinkedItemLabel {
|
||||
var unwrappedID: String { id ?? "" }
|
||||
var unwrappedName: String { name ?? "" }
|
||||
|
||||
static func named(_ name: String, inContext context: NSManagedObjectContext) -> LinkedItemLabel? {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(
|
||||
format: "name == %@", name
|
||||
)
|
||||
|
||||
var label: LinkedItemLabel?
|
||||
|
||||
context.performAndWait {
|
||||
label = (try? context.fetch(fetchRequest))?.first
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
static func lookup(byID id: String, inContext context: NSManagedObjectContext) -> LinkedItemLabel? {
|
||||
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
|
||||
|
|
@ -75,7 +90,7 @@ extension LinkedItemLabel {
|
|||
return label
|
||||
}
|
||||
|
||||
func update(
|
||||
internal func update(
|
||||
inContext context: NSManagedObjectContext,
|
||||
newName: String? = nil,
|
||||
newColor: String? = nil,
|
||||
|
|
@ -106,7 +121,7 @@ extension LinkedItemLabel {
|
|||
}
|
||||
}
|
||||
|
||||
func remove(inContext context: NSManagedObjectContext) {
|
||||
internal func remove(inContext context: NSManagedObjectContext) {
|
||||
context.perform {
|
||||
context.delete(self)
|
||||
|
||||
|
|
|
|||
|
|
@ -67,8 +67,10 @@ public extension NSNotification {
|
|||
)
|
||||
}
|
||||
|
||||
static func operationSuccess(message: String) {
|
||||
NotificationCenter.default.post(name: NSNotification.OperationSuccess, object: nil, userInfo: ["message": message])
|
||||
static func operationSuccess(message: String, undoAction: (() -> Void)?) {
|
||||
NotificationCenter.default.post(name: NSNotification.OperationSuccess,
|
||||
object: nil,
|
||||
userInfo: ["message": message, "undoAction": undoAction as Any])
|
||||
}
|
||||
|
||||
static func operationFailed(message: String) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ public extension Color {
|
|||
static var themeSolidBackground: Color { Color("_themeSolidBackground", bundle: .module) }
|
||||
static var thBorderColor: Color { Color("thBorderColor", bundle: .module) }
|
||||
|
||||
static var thFeatureSeparator: Color { Color("featureSeparator", bundle: .module) }
|
||||
|
||||
// Apple system UIColor equivalents
|
||||
#if os(iOS)
|
||||
static var systemBackground: Color { Color(.systemBackground) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xEB",
|
||||
"green" : "0xEB",
|
||||
"red" : "0xEB"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2A",
|
||||
"green" : "0x2A",
|
||||
"red" : "0x2A"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
1402
apple/OmnivoreKit/Sources/Views/FeedItem/File.swift
Normal file
|
|
@ -14,18 +14,16 @@ public struct GridCard: View {
|
|||
@Binding var isContextMenuOpen: Bool
|
||||
let item: LinkedItem
|
||||
let actionHandler: (GridCardAction) -> Void
|
||||
let tapAction: () -> Void
|
||||
// let tapAction: () -> Void
|
||||
|
||||
public init(
|
||||
item: LinkedItem,
|
||||
isContextMenuOpen: Binding<Bool>,
|
||||
actionHandler: @escaping (GridCardAction) -> Void,
|
||||
tapAction: @escaping () -> Void
|
||||
actionHandler: @escaping (GridCardAction) -> Void
|
||||
) {
|
||||
self.item = item
|
||||
self._isContextMenuOpen = isContextMenuOpen
|
||||
self.actionHandler = actionHandler
|
||||
self.tapAction = tapAction
|
||||
}
|
||||
|
||||
// Menu doesn't provide an API to observe it's open state
|
||||
|
|
@ -33,8 +31,6 @@ public struct GridCard: View {
|
|||
func tapHandler() {
|
||||
if isContextMenuOpen {
|
||||
isContextMenuOpen = false
|
||||
} else {
|
||||
tapAction()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +79,7 @@ public struct GridCard: View {
|
|||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onTapGesture { tapHandler() }
|
||||
// .onTapGesture { tapHandler() }
|
||||
|
||||
VStack {
|
||||
// Title, Subtitle, Menu Button
|
||||
|
|
@ -93,15 +89,6 @@ public struct GridCard: View {
|
|||
.font(.appHeadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
.onTapGesture { tapHandler() }
|
||||
Spacer()
|
||||
|
||||
Menu(
|
||||
content: { contextMenuView },
|
||||
label: { Image(systemName: "ellipsis").padding() }
|
||||
)
|
||||
.frame(width: 16, height: 16, alignment: .center)
|
||||
.onTapGesture { isContextMenuOpen = true }
|
||||
}
|
||||
|
||||
HStack {
|
||||
|
|
@ -121,7 +108,7 @@ public struct GridCard: View {
|
|||
|
||||
Spacer()
|
||||
}
|
||||
.onTapGesture { tapHandler() }
|
||||
// .onTapGesture { tapHandler() }
|
||||
}
|
||||
.frame(height: 30)
|
||||
.padding(.horizontal)
|
||||
|
|
@ -156,7 +143,7 @@ public struct GridCard: View {
|
|||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.onTapGesture { tapHandler() }
|
||||
// .onTapGesture { tapHandler() }
|
||||
|
||||
// Category Labels
|
||||
if item.hasLabels {
|
||||
|
|
@ -169,7 +156,7 @@ public struct GridCard: View {
|
|||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.onTapGesture { tapHandler() }
|
||||
// .onTapGesture { tapHandler() }
|
||||
}
|
||||
|
||||
if let status = item.serverSyncStatus, status != ServerSyncStatus.isNSync.rawValue {
|
||||
|
|
|
|||
|
|
@ -29,10 +29,10 @@ struct LabelsFlowLayout: View {
|
|||
var height = CGFloat.zero
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
ForEach(self.labelItems, id: \.self) { label in
|
||||
ForEach(Array(self.labelItems.enumerated()), id: \.offset) { _, label in
|
||||
self.item(for: label)
|
||||
.padding(.horizontal, 3)
|
||||
.padding(.vertical, 5)
|
||||
.padding(.trailing, 5)
|
||||
.padding(.bottom, 5)
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
|
|
@ -54,21 +54,19 @@ struct LabelsFlowLayout: View {
|
|||
return result
|
||||
})
|
||||
}
|
||||
}
|
||||
.background(viewHeightReader($totalHeight))
|
||||
}.background(viewCalculator())
|
||||
}
|
||||
|
||||
private func item(for item: LinkedItemLabel) -> some View {
|
||||
LibraryItemLabelView(text: item.name!, color: Color(hex: item.color!)!)
|
||||
}
|
||||
|
||||
private func viewHeightReader(_ binding: Binding<CGFloat>) -> some View {
|
||||
GeometryReader { geometry -> Color in
|
||||
let rect = geometry.frame(in: .local)
|
||||
DispatchQueue.main.async {
|
||||
binding.wrappedValue = rect.size.height
|
||||
func viewCalculator() -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.onAppear {
|
||||
let rect = geometry.frame(in: .local)
|
||||
self.totalHeight = rect.size.height
|
||||
}
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,11 +17,10 @@ public struct LibraryFeatureCard: View {
|
|||
VStack(alignment: .leading, spacing: 5) {
|
||||
imageBox
|
||||
title
|
||||
readInfo
|
||||
Spacer()
|
||||
}
|
||||
.padding(0)
|
||||
.frame(maxWidth: 150)
|
||||
|
||||
}.padding(0)
|
||||
.frame(maxWidth: 150)
|
||||
}
|
||||
|
||||
var isFullyRead: Bool {
|
||||
|
|
@ -32,63 +31,20 @@ public struct LibraryFeatureCard: View {
|
|||
Int(item.readingProgress) > 0
|
||||
}
|
||||
|
||||
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 readInfo: some View {
|
||||
AnyView(HStack {
|
||||
Text("\(estimatedReadingTime)")
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.themeMediumGray)
|
||||
+
|
||||
Text("\(readingProgress)")
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.foregroundColor(isPartiallyRead ? Color.appGreenSuccess : Color.themeMediumGray)
|
||||
}
|
||||
.frame(maxWidth: 150, alignment: .leading))
|
||||
}
|
||||
|
||||
var imageBox: some View {
|
||||
Group {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
if let imageURL = item.imageURL {
|
||||
AsyncImage(url: imageURL) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
Color.systemBackground
|
||||
.frame(width: 146, height: 90)
|
||||
.cornerRadius(5)
|
||||
EmptyView()
|
||||
case let .success(image):
|
||||
image.resizable()
|
||||
.frame(width: 146, height: 90)
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 146, height: 90)
|
||||
.cornerRadius(5)
|
||||
case .failure:
|
||||
Image(systemName: "photo")
|
||||
.frame(width: 146, height: 90)
|
||||
.foregroundColor(Color(hex: "#6A6968"))
|
||||
.background(Color(hex: "#EBEBEB"))
|
||||
.cornerRadius(5)
|
||||
fallbackImage
|
||||
|
||||
@unknown default:
|
||||
// Since the AsyncImagePhase enum isn't frozen,
|
||||
// we need to add this currently unused fallback
|
||||
|
|
@ -98,19 +54,32 @@ public struct LibraryFeatureCard: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "photo")
|
||||
.frame(width: 146, height: 90)
|
||||
.foregroundColor(Color(hex: "#6A6968"))
|
||||
.background(Color(hex: "#EBEBEB"))
|
||||
.cornerRadius(5)
|
||||
fallbackImage
|
||||
}
|
||||
Color(hex: "#D9D9D9")?.opacity(0.65).frame(width: 146, height: 5)
|
||||
Color(hex: "#FFD234").frame(width: 146 * (item.readingProgress / 100), height: 5)
|
||||
}
|
||||
.cornerRadius(5)
|
||||
}
|
||||
|
||||
var fallbackImage: some View {
|
||||
HStack {
|
||||
Text(item.unwrappedTitle.prefix(1))
|
||||
.font(Font.system(size: 128, weight: .bold))
|
||||
.offset(CGSize(width: -48, height: 12))
|
||||
.frame(alignment: .bottomLeading)
|
||||
.foregroundColor(Gradient.randomColor(str: item.unwrappedTitle, offset: 1))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Gradient.randomColor(str: item.unwrappedTitle, offset: 0))
|
||||
.background(LinearGradient(gradient: Gradient(fromStr: item.unwrappedTitle)!, startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 146, height: 90)
|
||||
}
|
||||
|
||||
var title: some View {
|
||||
Text(item.unwrappedTitle.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(Font.system(size: 13, weight: .semibold))
|
||||
.font(Font.system(size: 11, weight: .medium))
|
||||
.lineSpacing(1.25)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
|
|
|||
|
|
@ -26,18 +26,17 @@ public struct LibraryItemCard: View {
|
|||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
articleInfo
|
||||
imageBox
|
||||
}
|
||||
.padding(5)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
if item.hasLabels {
|
||||
labels
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.padding(.bottom, 5)
|
||||
.draggableItem(item: item)
|
||||
}
|
||||
|
||||
|
|
@ -83,13 +82,21 @@ public struct LibraryItemCard: View {
|
|||
if item.wordsCount > 0 {
|
||||
return "\(String(format: "%d", Int(item.readingProgress)))%"
|
||||
}
|
||||
if item.isPDF {
|
||||
// base estimated reading time on page count
|
||||
return "\(String(format: "%d", Int(item.readingProgress)))%"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var hasMultipleInfoItems: Bool {
|
||||
item.wordsCount > 0 || item.highlights?.first { ($0 as? Highlight)?.annotation != nil } != nil
|
||||
}
|
||||
|
||||
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 {
|
||||
if item.wordsCount > 0 || item.isPDF {
|
||||
return " • \(fmted)"
|
||||
}
|
||||
return fmted
|
||||
|
|
@ -107,7 +114,7 @@ public struct LibraryItemCard: View {
|
|||
|
||||
if let notes = notes, notes.count > 0 {
|
||||
let fmted = LocalText.pluralizedText(key: "number_of_notes", count: notes.count)
|
||||
if item.wordsCount > 0 {
|
||||
if hasMultipleInfoItems {
|
||||
return " • \(fmted)"
|
||||
}
|
||||
return fmted
|
||||
|
|
@ -196,11 +203,9 @@ public struct LibraryItemCard: View {
|
|||
byLine
|
||||
}
|
||||
.padding(0)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
|
||||
var labels: some View {
|
||||
LabelsFlowLayout(labels: item.sortedLabels)
|
||||
.padding(.top, 0)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ public struct LibraryItemLabelView: View {
|
|||
.cornerRadius(5)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.stroke(Color.themeLabelOutline, lineWidth: 1)
|
||||
.stroke(Color.isDarkMode ? Color.themeLabelBackground : Color.themeLabelOutline, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ public extension Image {
|
|||
static var smallOmnivoreLogo: Image { Image("_smallOmnivoreLogo", bundle: .module) }
|
||||
static var omnivoreTitleLogo: Image { Image("_omnivoreTitleLogo", bundle: .module) }
|
||||
static var googleIcon: Image { Image("_googleIcon", bundle: .module) }
|
||||
static var sunHorizon: Image { Image("_sun-horizon", bundle: .module) }
|
||||
static var mountains: Image { Image("_mountains", bundle: .module) }
|
||||
static var moon: Image { Image("_moon", bundle: .module) }
|
||||
static var moonStars: Image { Image("_moon-stars", bundle: .module) }
|
||||
static var chartLineUp: Image { Image("_chart-line-up", bundle: .module) }
|
||||
static var homeTab: Image { Image("_homeTab", bundle: .module) }
|
||||
|
||||
static var homeTab: Image { Image("BookmarksSimple", bundle: .module) }
|
||||
static var homeTabSelected: Image { Image("_homeTabSelected", bundle: .module) }
|
||||
static var profileTab: Image { Image("_profileTab", bundle: .module) }
|
||||
static var profileTabSelected: Image { Image("_profileTabSelected", bundle: .module) }
|
||||
static var dotsThree: Image { Image("_dots-three", bundle: .module) }
|
||||
|
||||
static var tabSubscriptions: Image { Image("_tab_subscriptions", bundle: .module).renderingMode(.template) }
|
||||
static var tabLibrary: Image { Image("_tab_library", bundle: .module).renderingMode(.template) }
|
||||
static var tabBriefing: Image { Image("_tab_briefing", bundle: .module).renderingMode(.template) }
|
||||
static var tabHighlights: Image { Image("_tab_highlights", bundle: .module).renderingMode(.template) }
|
||||
|
||||
static var pinRotated: Image { Image("pin-rotated", bundle: .module) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "sun-horizon.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32" viewBox="0 0 32 32" version="1.1">
|
||||
<g id="surface1">
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 92.8125 59 L 85.09375 40.5 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 43 108.8125 L 24.5 101.09375 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 213 108.8125 L 231.5 101.09375 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 163.1875 59 L 170.90625 40.5 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 240 160 L 16 160 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 208 200 L 48 200 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
<path style="fill:none;stroke-width:16;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(0%,0%,0%);stroke-opacity:1;stroke-miterlimit:4;" d="M 70.1875 160 C 63.40625 135.5625 72.6875 109.46875 93.4375 94.875 C 114.15625 80.25 141.84375 80.25 162.5625 94.875 C 183.3125 109.46875 192.59375 135.5625 185.8125 160 " transform="matrix(0.125,0,0,0.125,0,0)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
21
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Group 1000002644.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/_tab_briefing.imageset/Group 1000002644.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "HighlighterCircle.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "HighlighterCircle.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 482 B After Width: | Height: | Size: 482 B |
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "BookOpen.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "BookOpen.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
|
Before Width: | Height: | Size: 707 B After Width: | Height: | Size: 707 B |
|
|
@ -1,11 +1,11 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "BookmarksSimple.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "BookmarksSimple.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
23
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/pin-rotated.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "pin.fill 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "pin.fill.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "pin.fill 2.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/pin-rotated.imageset/pin.fill 1.png
vendored
Normal file
|
After Width: | Height: | Size: 315 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/pin-rotated.imageset/pin.fill 2.png
vendored
Normal file
|
After Width: | Height: | Size: 747 B |
BIN
apple/OmnivoreKit/Sources/Views/Images/Images.xcassets/pin-rotated.imageset/pin.fill.png
vendored
Normal file
|
After Width: | Height: | Size: 496 B |
|
|
@ -1,20 +1,32 @@
|
|||
import SwiftUI
|
||||
|
||||
public typealias SnackbarUndoAction = (() -> Void)
|
||||
|
||||
public struct SnackbarOperation {
|
||||
let message: String
|
||||
let undoAction: SnackbarUndoAction?
|
||||
|
||||
public init(message: String, undoAction: SnackbarUndoAction?) {
|
||||
self.message = message
|
||||
self.undoAction = undoAction
|
||||
}
|
||||
}
|
||||
|
||||
public struct Snackbar: View {
|
||||
@Binding var isShowing: Bool
|
||||
private let presentingView: AnyView
|
||||
private let text: Text
|
||||
private let operation: SnackbarOperation
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
|
||||
init<PresentingView>(
|
||||
isShowing: Binding<Bool>,
|
||||
presentingView: PresentingView,
|
||||
text: Text
|
||||
operation: SnackbarOperation
|
||||
) where PresentingView: View {
|
||||
self._isShowing = isShowing
|
||||
self.presentingView = AnyView(presentingView)
|
||||
self.text = text
|
||||
self.operation = operation
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
|
|
@ -23,12 +35,19 @@ public struct Snackbar: View {
|
|||
presentingView
|
||||
VStack {
|
||||
Spacer()
|
||||
if self.isShowing {
|
||||
if isShowing {
|
||||
HStack {
|
||||
self.text
|
||||
Text(operation.message)
|
||||
.font(.appCallout)
|
||||
.foregroundColor(self.colorScheme == .light ? .white : .appTextDefault)
|
||||
Spacer()
|
||||
if let undoAction = operation.undoAction {
|
||||
Button("Undo", action: {
|
||||
isShowing = false
|
||||
undoAction()
|
||||
})
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(width: min(380, geometry.size.width * 0.96), height: 44)
|
||||
|
|
@ -45,7 +64,11 @@ public struct Snackbar: View {
|
|||
}
|
||||
|
||||
public extension View {
|
||||
func snackBar(isShowing: Binding<Bool>, message: String?) -> some View {
|
||||
Snackbar(isShowing: isShowing, presentingView: self, text: Text(message ?? ""))
|
||||
func snackBar(isShowing: Binding<Bool>, operation: SnackbarOperation?) -> some View {
|
||||
if let operation = operation {
|
||||
return AnyView(Snackbar(isShowing: isShowing, presentingView: self, operation: operation))
|
||||
} else {
|
||||
return AnyView(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,151 +1,151 @@
|
|||
import Models
|
||||
import SwiftUI
|
||||
|
||||
public struct SnoozeView: View {
|
||||
@Binding var snoozePresented: Bool
|
||||
@Binding var itemToSnoozeID: String?
|
||||
let snoozeAction: (SnoozeActionParams) -> Void
|
||||
// public struct SnoozeView: View {
|
||||
// @Binding var snoozePresented: Bool
|
||||
// @Binding var itemToSnoozeID: String?
|
||||
// let snoozeAction: (SnoozeActionParams) -> Void
|
||||
//
|
||||
// public init(
|
||||
// snoozePresented: Binding<Bool>,
|
||||
// itemToSnoozeID: Binding<String?>,
|
||||
// snoozeAction: @escaping (SnoozeActionParams) -> Void
|
||||
// ) {
|
||||
// self._snoozePresented = snoozePresented
|
||||
// self._itemToSnoozeID = itemToSnoozeID
|
||||
// self.snoozeAction = snoozeAction
|
||||
// }
|
||||
//
|
||||
// public var body: some View {
|
||||
// VStack {
|
||||
// Spacer()
|
||||
//
|
||||
// HStack {
|
||||
// SnoozeIconButtonView(snooze: Snooze.currentValues[0], action: { snoozeItem($0) })
|
||||
// SnoozeIconButtonView(snooze: Snooze.currentValues[1], action: { snoozeItem($0) })
|
||||
// }
|
||||
//
|
||||
// Spacer(minLength: 32)
|
||||
//
|
||||
// HStack {
|
||||
// SnoozeIconButtonView(snooze: Snooze.currentValues[2], action: { snoozeItem($0) })
|
||||
// SnoozeIconButtonView(snooze: Snooze.currentValues[3], action: { snoozeItem($0) })
|
||||
// }
|
||||
// Spacer()
|
||||
// }.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
// }
|
||||
//
|
||||
// private func snoozeItem(_ snooze: Snooze) {
|
||||
// if let itemID = itemToSnoozeID {
|
||||
// withAnimation(.linear(duration: 0.4)) {
|
||||
// snoozeAction(
|
||||
// SnoozeActionParams(
|
||||
// feedItemId: itemID,
|
||||
// snoozeUntilDate: snooze.until,
|
||||
// successMessage: "Snoozed until \(snooze.untilStr)"
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// itemToSnoozeID = nil
|
||||
// snoozePresented = false
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public struct SnoozeActionParams {
|
||||
// public let feedItemId: String
|
||||
// public let snoozeUntilDate: Date
|
||||
// public let successMessage: String?
|
||||
// }
|
||||
//
|
||||
// private struct SnoozeIconButtonView: View {
|
||||
// let snooze: Snooze
|
||||
// let action: (_ snooze: Snooze) -> Void
|
||||
//
|
||||
// var body: some View {
|
||||
// Button(
|
||||
// action: { action(snooze) },
|
||||
// label: {
|
||||
// VStack(alignment: .center, spacing: 8) {
|
||||
// snooze.icon
|
||||
// .font(.appTitle)
|
||||
// .foregroundColor(.appYellow48)
|
||||
// Text(snooze.title)
|
||||
// .font(.appBody)
|
||||
// .foregroundColor(.appGrayText)
|
||||
// Text(snooze.untilStr)
|
||||
// .font(.appCaption)
|
||||
// .foregroundColor(.appGrayText)
|
||||
// }
|
||||
// .frame(
|
||||
// maxWidth: .infinity,
|
||||
// maxHeight: .infinity
|
||||
// )
|
||||
// }
|
||||
// )
|
||||
// .frame(height: 100)
|
||||
// }
|
||||
// }
|
||||
|
||||
public init(
|
||||
snoozePresented: Binding<Bool>,
|
||||
itemToSnoozeID: Binding<String?>,
|
||||
snoozeAction: @escaping (SnoozeActionParams) -> Void
|
||||
) {
|
||||
self._snoozePresented = snoozePresented
|
||||
self._itemToSnoozeID = itemToSnoozeID
|
||||
self.snoozeAction = snoozeAction
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
SnoozeIconButtonView(snooze: Snooze.currentValues[0], action: { snoozeItem($0) })
|
||||
SnoozeIconButtonView(snooze: Snooze.currentValues[1], action: { snoozeItem($0) })
|
||||
}
|
||||
|
||||
Spacer(minLength: 32)
|
||||
|
||||
HStack {
|
||||
SnoozeIconButtonView(snooze: Snooze.currentValues[2], action: { snoozeItem($0) })
|
||||
SnoozeIconButtonView(snooze: Snooze.currentValues[3], action: { snoozeItem($0) })
|
||||
}
|
||||
Spacer()
|
||||
}.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
|
||||
private func snoozeItem(_ snooze: Snooze) {
|
||||
if let itemID = itemToSnoozeID {
|
||||
withAnimation(.linear(duration: 0.4)) {
|
||||
snoozeAction(
|
||||
SnoozeActionParams(
|
||||
feedItemId: itemID,
|
||||
snoozeUntilDate: snooze.until,
|
||||
successMessage: "Snoozed until \(snooze.untilStr)"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
itemToSnoozeID = nil
|
||||
snoozePresented = false
|
||||
}
|
||||
}
|
||||
|
||||
public struct SnoozeActionParams {
|
||||
public let feedItemId: String
|
||||
public let snoozeUntilDate: Date
|
||||
public let successMessage: String?
|
||||
}
|
||||
|
||||
private struct SnoozeIconButtonView: View {
|
||||
let snooze: Snooze
|
||||
let action: (_ snooze: Snooze) -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(
|
||||
action: { action(snooze) },
|
||||
label: {
|
||||
VStack(alignment: .center, spacing: 8) {
|
||||
snooze.icon
|
||||
.font(.appTitle)
|
||||
.foregroundColor(.appYellow48)
|
||||
Text(snooze.title)
|
||||
.font(.appBody)
|
||||
.foregroundColor(.appGrayText)
|
||||
Text(snooze.untilStr)
|
||||
.font(.appCaption)
|
||||
.foregroundColor(.appGrayText)
|
||||
}
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}
|
||||
)
|
||||
.frame(height: 100)
|
||||
}
|
||||
}
|
||||
|
||||
struct Snooze {
|
||||
let until: Date
|
||||
let icon: Image
|
||||
let title: String
|
||||
let untilStr: String
|
||||
|
||||
init(until: Date, icon: Image, title: String, needsDay: Bool) {
|
||||
self.until = until
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = needsDay ? "EEE h:mm a" : "h:mm a"
|
||||
self.untilStr = formatter.string(from: until)
|
||||
}
|
||||
|
||||
static var currentValues: [Snooze] {
|
||||
calculateValues(for: Date(), calendar: Calendar.current)
|
||||
}
|
||||
|
||||
static func calculateValues(for now: Date, calendar: Calendar) -> [Snooze] {
|
||||
var res: [Snooze] = []
|
||||
let components = calendar.dateComponents([.year, .month, .day, .hour, .timeZone, .weekday], from: now)
|
||||
|
||||
var tonightComponent = components
|
||||
tonightComponent.hour = 20
|
||||
|
||||
var thisMorningComponent = components
|
||||
thisMorningComponent.hour = 8
|
||||
|
||||
let tonight = calendar.date(from: tonightComponent)!
|
||||
let thisMorning = calendar.date(from: thisMorningComponent)!
|
||||
|
||||
let tomorrowMorning = Calendar.current.date(byAdding: DateComponents(day: 1), to: thisMorning)
|
||||
|
||||
// Add either tonight or tomorrow night
|
||||
if now < tonight {
|
||||
res.append(Snooze(until: tonight, icon: .moonStars, title: "Tonight", needsDay: false))
|
||||
} else {
|
||||
let tomorrowNight = Calendar.current.date(byAdding: DateComponents(day: 1), to: tonight)!
|
||||
res.append(Snooze(until: tomorrowNight, icon: .moonStars, title: "Tomorrow night", needsDay: false))
|
||||
}
|
||||
|
||||
if let tomorrowMorning = tomorrowMorning {
|
||||
res.append(Snooze(until: tomorrowMorning, icon: .sunHorizon, title: "Tomorrow morning", needsDay: false))
|
||||
}
|
||||
|
||||
if let weekday = components.weekday {
|
||||
// Add this or next weekend
|
||||
if weekday < 5 {
|
||||
let thisWeekend = Calendar.current.date(byAdding: DateComponents(day: 7 - weekday), to: thisMorning)
|
||||
res.append(Snooze(until: thisWeekend!, icon: .mountains, title: "This weekend", needsDay: true))
|
||||
} else {
|
||||
let nextWeekend = Calendar.current.date(byAdding: DateComponents(day: 7 - (weekday - 5)), to: thisMorning)!
|
||||
res.append(Snooze(until: nextWeekend, icon: .mountains, title: "Next weekend", needsDay: true))
|
||||
}
|
||||
let nextWeek = Calendar.current.date(byAdding: DateComponents(day: weekday + 5), to: thisMorning)!
|
||||
res.append(Snooze(until: nextWeek, icon: .chartLineUp, title: "Next week", needsDay: true))
|
||||
}
|
||||
|
||||
return Array(res.sorted(by: { $0.until > $1.until }).reversed())
|
||||
}
|
||||
}
|
||||
// struct Snooze {
|
||||
// let until: Date
|
||||
// let icon: Image
|
||||
// let title: String
|
||||
// let untilStr: String
|
||||
//
|
||||
// init(until: Date, icon: Image, title: String, needsDay: Bool) {
|
||||
// self.until = until
|
||||
// self.icon = icon
|
||||
// self.title = title
|
||||
// let formatter = DateFormatter()
|
||||
// formatter.dateFormat = needsDay ? "EEE h:mm a" : "h:mm a"
|
||||
// self.untilStr = formatter.string(from: until)
|
||||
// }
|
||||
//
|
||||
// static var currentValues: [Snooze] {
|
||||
// calculateValues(for: Date(), calendar: Calendar.current)
|
||||
// }
|
||||
//
|
||||
// static func calculateValues(for now: Date, calendar: Calendar) -> [Snooze] {
|
||||
// var res: [Snooze] = []
|
||||
// let components = calendar.dateComponents([.year, .month, .day, .hour, .timeZone, .weekday], from: now)
|
||||
//
|
||||
// var tonightComponent = components
|
||||
// tonightComponent.hour = 20
|
||||
//
|
||||
// var thisMorningComponent = components
|
||||
// thisMorningComponent.hour = 8
|
||||
//
|
||||
// let tonight = calendar.date(from: tonightComponent)!
|
||||
// let thisMorning = calendar.date(from: thisMorningComponent)!
|
||||
//
|
||||
// let tomorrowMorning = Calendar.current.date(byAdding: DateComponents(day: 1), to: thisMorning)
|
||||
//
|
||||
// // Add either tonight or tomorrow night
|
||||
// if now < tonight {
|
||||
// res.append(Snooze(until: tonight, icon: .moonStars, title: "Tonight", needsDay: false))
|
||||
// } else {
|
||||
// let tomorrowNight = Calendar.current.date(byAdding: DateComponents(day: 1), to: tonight)!
|
||||
// res.append(Snooze(until: tomorrowNight, icon: .moonStars, title: "Tomorrow night", needsDay: false))
|
||||
// }
|
||||
//
|
||||
// if let tomorrowMorning = tomorrowMorning {
|
||||
// res.append(Snooze(until: tomorrowMorning, icon: .sunHorizon, title: "Tomorrow morning", needsDay: false))
|
||||
// }
|
||||
//
|
||||
// if let weekday = components.weekday {
|
||||
// // Add this or next weekend
|
||||
// if weekday < 5 {
|
||||
// let thisWeekend = Calendar.current.date(byAdding: DateComponents(day: 7 - weekday), to: thisMorning)
|
||||
// res.append(Snooze(until: thisWeekend!, icon: .mountains, title: "This weekend", needsDay: true))
|
||||
// } else {
|
||||
// let nextWeekend = Calendar.current.date(byAdding: DateComponents(day: 7 - (weekday - 5)), to: thisMorning)!
|
||||
// res.append(Snooze(until: nextWeekend, icon: .mountains, title: "Next weekend", needsDay: true))
|
||||
// }
|
||||
// let nextWeek = Calendar.current.date(byAdding: DateComponents(day: weekday + 5), to: thisMorning)!
|
||||
// res.append(Snooze(until: nextWeek, icon: .chartLineUp, title: "Next week", needsDay: true))
|
||||
// }
|
||||
//
|
||||
// return Array(res.sorted(by: { $0.until > $1.until }).reversed())
|
||||
// }
|
||||
// }
|
||||
|
|
|
|||
105
apple/OmnivoreKit/Sources/Views/SwiftUIDelayedGesture.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
//
|
||||
// FROM: https://github.com/ciaranrobrien/SwiftUIDelayedGesture/tree/main
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public extension View {
|
||||
/// Sequences a gesture with a long press and attaches the result to the view,
|
||||
/// which results in the gesture only receiving events after the long press
|
||||
/// succeeds.
|
||||
///
|
||||
/// Use this view modifier *instead* of `.gesture` to delay a gesture:
|
||||
///
|
||||
/// ScrollView {
|
||||
/// FooView()
|
||||
/// .delayedGesture(someGesture, delay: 0.2)
|
||||
/// }
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - gesture: A gesture to attach to the view.
|
||||
/// - mask: A value that controls how adding this gesture to the view
|
||||
/// affects other gestures recognized by the view and its subviews.
|
||||
/// - delay: A value that controls the duration of the long press that
|
||||
/// must elapse before the gesture can be recognized by the view.
|
||||
/// - action: An action to perform if a tap gesture is recognized
|
||||
/// before the long press can be recognized by the view.
|
||||
func delayedGesture<T: Gesture>(_ gesture: T,
|
||||
including mask: GestureMask = .all,
|
||||
delay: TimeInterval = 0.25,
|
||||
onTapGesture action: @escaping () -> Void = {}) -> some View
|
||||
{
|
||||
modifier(DelaysTouches(duration: delay, action: action))
|
||||
.gesture(gesture, including: mask)
|
||||
}
|
||||
|
||||
/// Attaches a long press gesture to the view, which results in gestures with a
|
||||
/// lower precedence only receiving events after the long press succeeds.
|
||||
///
|
||||
/// Use this view modifier *before* `.gesture` to delay a gesture:
|
||||
///
|
||||
/// ScrollView {
|
||||
/// FooView()
|
||||
/// .delayedInput(delay: 0.2)
|
||||
/// .gesture(someGesture)
|
||||
/// }
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - delay: A value that controls the duration of the long press that
|
||||
/// must elapse before lower precedence gestures can be recognized by
|
||||
/// the view.
|
||||
/// - action: An action to perform if a tap gesture is recognized
|
||||
/// before the long press can be recognized by the view.
|
||||
func delayedInput(delay: TimeInterval = 0.25,
|
||||
onTapGesture action: @escaping () -> Void = {}) -> some View
|
||||
{
|
||||
modifier(DelaysTouches(duration: delay, action: action))
|
||||
}
|
||||
}
|
||||
|
||||
private struct DelaysTouches: ViewModifier {
|
||||
@State private var disabled = false
|
||||
@State private var touchDownDate: Date? = nil
|
||||
|
||||
var duration: TimeInterval
|
||||
var action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Button(action: action) {
|
||||
content
|
||||
}
|
||||
.buttonStyle(DelaysTouchesButtonStyle(disabled: $disabled, duration: duration, touchDownDate: $touchDownDate))
|
||||
.disabled(disabled)
|
||||
}
|
||||
}
|
||||
|
||||
private struct DelaysTouchesButtonStyle: ButtonStyle {
|
||||
@Binding var disabled: Bool
|
||||
var duration: TimeInterval
|
||||
@Binding var touchDownDate: Date?
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.onChange(of: configuration.isPressed, perform: handleIsPressed)
|
||||
}
|
||||
|
||||
private func handleIsPressed(isPressed: Bool) {
|
||||
if isPressed {
|
||||
let date = Date()
|
||||
touchDownDate = date
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + max(duration, 0)) {
|
||||
if date == touchDownDate {
|
||||
disabled = true
|
||||
|
||||
DispatchQueue.main.async {
|
||||
disabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
touchDownDate = nil
|
||||
disabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,7 +17,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
|
|||
const isDark = isDarkTheme()
|
||||
|
||||
const selectedBorder = isDark ? '#FFEA9F' : 'black'
|
||||
const unSelectedBorder = isDark ? '#6A6968' : '#D9D9D9'
|
||||
const unSelectedBorder = isDark ? '#2A2A2A' : '#D9D9D9'
|
||||
|
||||
return (
|
||||
<SpanBox
|
||||
|
|
@ -27,7 +27,7 @@ export function LabelChip(props: LabelChipProps): JSX.Element {
|
|||
fontSize: '11px',
|
||||
fontWeight: '500',
|
||||
fontFamily: '$inter',
|
||||
padding: '1px 7px',
|
||||
padding: '4px 7px',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
backgroundClip: 'padding-box',
|
||||
|
|
|
|||