Merge pull request #1283 from omnivore-app/feature/highlights-list-view-ios
This commit is contained in:
@ -0,0 +1,136 @@
|
||||
import Models
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct HighlightsListCard: View {
|
||||
@State var isContextMenuOpen = false
|
||||
@State var annotation = String()
|
||||
@State var showAnnotationModal = false
|
||||
|
||||
let highlightParams: HighlightListItemParams
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
let onSaveAnnotation: (String) -> Void
|
||||
let onDeleteHighlight: () -> Void
|
||||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = highlightParams.quote
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
let pasteBoard = NSPasteboard.general
|
||||
pasteBoard.clearContents()
|
||||
pasteBoard.writeObjects([highlightParams.quote as NSString])
|
||||
#endif
|
||||
|
||||
Snackbar.show(message: "Highlight copied")
|
||||
},
|
||||
label: { Label("Copy", systemImage: "doc.on.doc") }
|
||||
)
|
||||
Button(
|
||||
action: onDeleteHighlight,
|
||||
label: { Label("Delete", systemImage: "trash") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var noteSection: some View {
|
||||
Group {
|
||||
HStack {
|
||||
Image(systemName: "note.text")
|
||||
|
||||
Text("Note")
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text(highlightParams.annotation)
|
||||
}
|
||||
.onTapGesture {
|
||||
annotation = highlightParams.annotation
|
||||
showAnnotationModal = true
|
||||
}
|
||||
}
|
||||
|
||||
var addNoteSection: some View {
|
||||
HStack {
|
||||
Image(systemName: "note.text.badge.plus").foregroundColor(.appGrayTextContrast)
|
||||
|
||||
Text("Add a Note")
|
||||
.font(.appSubheadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.onTapGesture {
|
||||
annotation = highlightParams.annotation
|
||||
showAnnotationModal = true
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Image(systemName: "highlighter")
|
||||
|
||||
Text(highlightParams.title)
|
||||
.font(.appHeadline)
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Menu(
|
||||
content: { contextMenuView },
|
||||
label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
.padding()
|
||||
}
|
||||
)
|
||||
.frame(width: 16, height: 16, alignment: .center)
|
||||
.onTapGesture { isContextMenuOpen = true }
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
HStack {
|
||||
Divider()
|
||||
.frame(width: 6)
|
||||
.overlay(Color.appYellow48)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(highlightParams.quote)
|
||||
|
||||
Divider()
|
||||
|
||||
if highlightParams.annotation.isEmpty {
|
||||
addNoteSection
|
||||
} else {
|
||||
noteSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.sheet(isPresented: $showAnnotationModal) {
|
||||
HighlightAnnotationSheet(
|
||||
annotation: $annotation,
|
||||
onSave: {
|
||||
onSaveAnnotation(annotation)
|
||||
showAnnotationModal = false
|
||||
hasHighlightMutations = true
|
||||
},
|
||||
onCancel: {
|
||||
showAnnotationModal = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct HighlightsListView: View {
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
@StateObject var viewModel = HighlightsListViewModel()
|
||||
|
||||
let itemObjectID: NSManagedObjectID
|
||||
@Binding var hasHighlightMutations: Bool
|
||||
|
||||
var innerBody: some View {
|
||||
List {
|
||||
Section {
|
||||
ForEach(viewModel.highlightItems) { highlightParams in
|
||||
HighlightsListCard(
|
||||
highlightParams: highlightParams,
|
||||
hasHighlightMutations: $hasHighlightMutations,
|
||||
onSaveAnnotation: {
|
||||
viewModel.updateAnnotation(
|
||||
highlightID: highlightParams.highlightID,
|
||||
annotation: $0,
|
||||
dataService: dataService
|
||||
)
|
||||
},
|
||||
onDeleteHighlight: {
|
||||
hasHighlightMutations = true
|
||||
|
||||
viewModel.deleteHighlight(
|
||||
highlightID: highlightParams.highlightID,
|
||||
dataService: dataService
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Highlights")
|
||||
.listStyle(PlainListStyle())
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#else
|
||||
.toolbar {
|
||||
ToolbarItemGroup {
|
||||
dismissButton
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var dismissButton: some View {
|
||||
Button(
|
||||
action: { presentationMode.wrappedValue.dismiss() },
|
||||
label: { Text("Done").foregroundColor(.appGrayTextContrast) }
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(iOS)
|
||||
NavigationView {
|
||||
innerBody
|
||||
}
|
||||
#elseif os(macOS)
|
||||
innerBody
|
||||
.frame(minWidth: 400, minHeight: 400)
|
||||
#endif
|
||||
}
|
||||
.task {
|
||||
viewModel.load(itemObjectID: itemObjectID, dataService: dataService)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import CoreData
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
struct HighlightListItemParams: Identifiable {
|
||||
let id = UUID()
|
||||
let highlightID: String
|
||||
let title: String
|
||||
let annotation: String
|
||||
let quote: String
|
||||
}
|
||||
|
||||
@MainActor final class HighlightsListViewModel: ObservableObject {
|
||||
@Published var highlightItems = [HighlightListItemParams]()
|
||||
|
||||
func load(itemObjectID: NSManagedObjectID, dataService: DataService) {
|
||||
if let linkedItem = dataService.viewContext.object(with: itemObjectID) as? LinkedItem {
|
||||
loadHighlights(item: linkedItem)
|
||||
}
|
||||
}
|
||||
|
||||
func updateAnnotation(highlightID: String, annotation: String, dataService: DataService) {
|
||||
dataService.updateHighlightAttributes(highlightID: highlightID, annotation: annotation)
|
||||
|
||||
if let index = highlightItems.firstIndex(where: { $0.highlightID == highlightID }) {
|
||||
highlightItems[index] = HighlightListItemParams(
|
||||
highlightID: highlightID,
|
||||
title: highlightItems[index].title,
|
||||
annotation: annotation,
|
||||
quote: highlightItems[index].quote
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteHighlight(highlightID: String, dataService: DataService) {
|
||||
dataService.deleteHighlight(highlightID: highlightID)
|
||||
highlightItems.removeAll { $0.highlightID == highlightID }
|
||||
}
|
||||
|
||||
private func loadHighlights(item: LinkedItem) {
|
||||
let unsortedHighlights = item.highlights.asArray(of: Highlight.self)
|
||||
|
||||
let highlights = unsortedHighlights.sorted {
|
||||
($0.createdAt ?? Date()) < ($1.createdAt ?? Date())
|
||||
}
|
||||
|
||||
highlightItems = highlights.map {
|
||||
HighlightListItemParams(
|
||||
highlightID: $0.unwrappedID,
|
||||
title: "Highlight",
|
||||
annotation: $0.annotation ?? "",
|
||||
quote: $0.quote ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ import Views
|
||||
private let enableGrid = UIDevice.isIPad || FeatureFlag.enableGridCardsOnPhone
|
||||
|
||||
struct HomeFeedContainerView: View {
|
||||
@State var hasHighlightMutations = false
|
||||
@EnvironmentObject var dataService: DataService
|
||||
@EnvironmentObject var audioController: AudioController
|
||||
|
||||
@ -62,6 +63,9 @@ import Views
|
||||
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
|
||||
LinkedItemTitleEditView(item: item)
|
||||
}
|
||||
.sheet(item: $viewModel.itemForHighlightsView) { item in
|
||||
HighlightsListView(itemObjectID: item.objectID, hasHighlightMutations: $hasHighlightMutations)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .barTrailing) {
|
||||
Button("", action: {})
|
||||
@ -255,6 +259,10 @@ import Views
|
||||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
Button(
|
||||
action: { viewModel.itemForHighlightsView = item },
|
||||
label: { Label("View Highlights", systemImage: "highlighter") }
|
||||
)
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
@ -372,6 +380,8 @@ import Views
|
||||
|
||||
func contextMenuActionHandler(item: LinkedItem, action: GridCardAction) {
|
||||
switch action {
|
||||
case .viewHighlights:
|
||||
viewModel.itemForHighlightsView = item
|
||||
case .toggleArchiveStatus:
|
||||
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: !item.isArchived)
|
||||
case .delete:
|
||||
|
||||
@ -36,6 +36,7 @@ import Views
|
||||
viewModel: viewModel
|
||||
)
|
||||
.contextMenu {
|
||||
// TODO: add highlights view button
|
||||
Button(
|
||||
action: { viewModel.itemUnderTitleEdit = item },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
@ -137,6 +138,7 @@ import Views
|
||||
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
|
||||
LinkedItemTitleEditView(item: item)
|
||||
}
|
||||
// TODO: add highlights view sheet
|
||||
.task {
|
||||
if viewModel.items.isEmpty {
|
||||
loadItems(isRefresh: true)
|
||||
|
||||
@ -17,6 +17,7 @@ import Views
|
||||
@Published var showPushNotificationPrimer = false
|
||||
@Published var itemUnderLabelEdit: LinkedItem?
|
||||
@Published var itemUnderTitleEdit: LinkedItem?
|
||||
@Published var itemForHighlightsView: LinkedItem?
|
||||
@Published var searchTerm = ""
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var negatedLabels = [LinkedItemLabel]()
|
||||
|
||||
@ -70,6 +70,7 @@ struct WebReader: PlatformViewRepresentable {
|
||||
context.coordinator.linkHandler = openLinkAction
|
||||
context.coordinator.webViewActionHandler = webViewActionHandler
|
||||
context.coordinator.updateNavBarVisibilityRatio = navBarVisibilityRatioUpdater
|
||||
context.coordinator.articleContentID = articleContent.id
|
||||
loadContent(webView: webView)
|
||||
|
||||
return webView
|
||||
@ -101,8 +102,10 @@ struct WebReader: PlatformViewRepresentable {
|
||||
}
|
||||
|
||||
// If the webview had been terminated `needsReload` will have been set to true
|
||||
if context.coordinator.needsReload {
|
||||
// Or if the articleContent value has changed then it's id will be different from the coordinator's
|
||||
if context.coordinator.needsReload || context.coordinator.articleContentID != articleContent.id {
|
||||
loadContent(webView: webView)
|
||||
context.coordinator.articleContentID = articleContent.id
|
||||
context.coordinator.needsReload = false
|
||||
return
|
||||
}
|
||||
|
||||
@ -12,6 +12,8 @@ struct WebReaderContainerView: View {
|
||||
@State private var showPreferencesPopover = false
|
||||
@State private var showLabelsModal = false
|
||||
@State private var showTitleEdit = false
|
||||
@State private var showHighlightsView = false
|
||||
@State private var hasPerformedHighlightMutations = false
|
||||
@State var showHighlightAnnotationModal = false
|
||||
@State var safariWebLink: SafariWebLink?
|
||||
@State private var navBarVisibilityRatio = 1.0
|
||||
@ -43,6 +45,23 @@ struct WebReaderContainerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func onHighlightListViewDismissal() {
|
||||
// Reload the web view if mutation happened in highlights list modal
|
||||
guard hasPerformedHighlightMutations else { return }
|
||||
|
||||
hasPerformedHighlightMutations.toggle()
|
||||
|
||||
Task {
|
||||
if let username = dataService.currentViewer?.username {
|
||||
await viewModel.loadContent(
|
||||
dataService: dataService,
|
||||
username: username,
|
||||
itemID: item.unwrappedID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHighlightAction(message: WKScriptMessage) {
|
||||
guard let messageBody = message.body as? [String: String] else { return }
|
||||
guard let actionID = messageBody["actionID"] else { return }
|
||||
@ -131,6 +150,10 @@ struct WebReaderContainerView: View {
|
||||
Menu(
|
||||
content: {
|
||||
Group {
|
||||
Button(
|
||||
action: { showHighlightsView = true },
|
||||
label: { Label("View Highlights", systemImage: "highlighter") }
|
||||
)
|
||||
Button(
|
||||
action: { showTitleEdit = true },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
@ -198,6 +221,12 @@ struct WebReaderContainerView: View {
|
||||
.sheet(isPresented: $showTitleEdit) {
|
||||
LinkedItemTitleEditView(item: item)
|
||||
}
|
||||
.sheet(isPresented: $showHighlightsView, onDismiss: onHighlightListViewDismissal) {
|
||||
HighlightsListView(
|
||||
itemObjectID: item.objectID,
|
||||
hasHighlightMutations: $hasPerformedHighlightMutations
|
||||
)
|
||||
}
|
||||
#if os(macOS)
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
#endif
|
||||
|
||||
@ -19,6 +19,7 @@ final class WebReaderCoordinator: NSObject {
|
||||
var previousShowNavBarActionID: UUID?
|
||||
var previousShareActionID: UUID?
|
||||
var updateNavBarVisibilityRatio: (Double) -> Void = { _ in }
|
||||
var articleContentID = UUID()
|
||||
private var yOffsetAtStartOfDrag: Double?
|
||||
private var lastYOffset: Double = 0
|
||||
private var hasDragged = false
|
||||
|
||||
@ -9,6 +9,7 @@ public enum ArticleContentStatus: String {
|
||||
}
|
||||
|
||||
public struct ArticleContent {
|
||||
public let id = UUID()
|
||||
public let title: String
|
||||
public let htmlContent: String
|
||||
public let highlightsJSONString: String
|
||||
|
||||
@ -87,6 +87,12 @@ public extension LinkedItem {
|
||||
}
|
||||
}
|
||||
|
||||
var sortedHighlights: [Highlight] {
|
||||
highlights.asArray(of: Highlight.self).sorted {
|
||||
($0.createdAt ?? Date()) < ($1.createdAt ?? Date())
|
||||
}
|
||||
}
|
||||
|
||||
var labelsJSONString: String {
|
||||
let labels = self.labels.asArray(of: LinkedItemLabel.self).map { label in
|
||||
[
|
||||
|
||||
@ -15,4 +15,5 @@ public enum FeatureFlag {
|
||||
public static let enableSnooze = false
|
||||
public static let enableGridCardsOnPhone = false
|
||||
public static let enableTextToSpeechButton = true
|
||||
public static let enableHighlightsView = true
|
||||
}
|
||||
|
||||
@ -22,15 +22,13 @@ public struct HighlightAnnotationSheet: View {
|
||||
HStack {
|
||||
Button("Cancel", action: onCancel)
|
||||
Spacer()
|
||||
HStack {
|
||||
Image(systemName: "note.text")
|
||||
Text("Note")
|
||||
}
|
||||
Label("Note", systemImage: "note.text")
|
||||
Spacer()
|
||||
Button("Save") {
|
||||
onSave()
|
||||
}
|
||||
}
|
||||
.foregroundColor(.appGrayTextContrast)
|
||||
|
||||
ScrollView {
|
||||
TextEditor(text: $annotation)
|
||||
|
||||
@ -8,6 +8,7 @@ public enum GridCardAction {
|
||||
case editLabels
|
||||
case editTitle
|
||||
case downloadAudio
|
||||
case viewHighlights
|
||||
}
|
||||
|
||||
public struct GridCard: View {
|
||||
@ -45,6 +46,10 @@ public struct GridCard: View {
|
||||
|
||||
var contextMenuView: some View {
|
||||
Group {
|
||||
Button(
|
||||
action: { menuActionHandler(.viewHighlights) },
|
||||
label: { Label("View Highlights", systemImage: "highlighter") }
|
||||
)
|
||||
Button(
|
||||
action: { menuActionHandler(.editTitle) },
|
||||
label: { Label("Edit Title/Description", systemImage: "textbox") }
|
||||
|
||||
Reference in New Issue
Block a user