Merge pull request #2471 from omnivore-app/feat/ios-tab-view

Multi tab iOS client
This commit is contained in:
Jackson Harper 2023-07-18 12:12:53 +08:00 committed by GitHub
commit cd5a9960cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 3776 additions and 759 deletions

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] ?? [])
}
}

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ public enum ArticleContentStatus: String {
case processing = "PROCESSING"
case succeeded = "SUCCEEDED"
case unknown = "UNKNOWN"
case deleted = "DELETED"
}
public struct ArticleContent {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -41,7 +41,7 @@ public extension DataService {
try $0.setLabelsForHighlight(
input: InputObjects.SetLabelsForHighlightInput(
highlightId: highlightID,
labelIds: labelIDs
labelIds: OptionalArgument(labelIDs)
),
selection: selection
)

View file

@ -43,7 +43,7 @@ public extension DataService {
username: username,
requestCount: requestCount + 1
)
case .succeeded, .unknown:
case .succeeded, .unknown, .deleted:
return fetchedContent
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,15 +0,0 @@
{
"images" : [
{
"filename" : "sun-horizon.svg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}

View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -1,11 +1,11 @@
{
"images" : [
{
"filename" : "HighlighterCircle.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "HighlighterCircle.png",
"idiom" : "universal",
"scale" : "2x"
},

View file

@ -1,11 +1,11 @@
{
"images" : [
{
"filename" : "BookOpen.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "BookOpen.png",
"idiom" : "universal",
"scale" : "2x"
},

View file

@ -1,11 +1,11 @@
{
"images" : [
{
"filename" : "BookmarksSimple.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "BookmarksSimple.png",
"idiom" : "universal",
"scale" : "2x"
},

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

File diff suppressed because one or more lines are too long

View file

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

View file

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

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

View file

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