Merge pull request #1691 from omnivore-app/feat/improve-ios-share-ext

Improve iOS share extension and syncing
This commit is contained in:
Satindar Dhillon 2023-01-24 08:27:34 -08:00 committed by GitHub
commit 19fe4087f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 292 additions and 378 deletions

View file

@ -1,8 +1,11 @@
import SwiftUI
import Utils
import Views
public extension PlatformViewController {
static func makeShareExtensionController(extensionContext: NSExtensionContext?) -> PlatformViewController {
registerFonts()
let hostingController = PlatformHostingController(
rootView: ShareExtensionView(extensionContext: extensionContext)
)

View file

@ -4,9 +4,9 @@ import SwiftUI
import Utils
import Views
// swiftlint:disable:next type_body_length
public struct ShareExtensionView: View {
let extensionContext: NSExtensionContext?
@EnvironmentObject var dataService: DataService
@StateObject var labelsViewModel = LabelsViewModel()
@StateObject private var viewModel = ShareExtensionViewModel()
@ -14,6 +14,7 @@ public struct ShareExtensionView: View {
@State var hideUntilReminded = false
@State var previousLabels: [LinkedItemLabel]?
@State var messageText: String?
@State var showSearchLabels = false
@State var viewState = ViewState.mainView
@State var showHighlightInstructionAlert = false
@ -128,7 +129,7 @@ public struct ShareExtensionView: View {
.font(.appFootnote)
.foregroundColor(.appGrayText)
.frame(maxWidth: .infinity, alignment: .leading)
} else {}
}
}
.frame(maxWidth: .infinity, maxHeight: 60)
.padding()
@ -173,31 +174,37 @@ public struct ShareExtensionView: View {
Image(systemName: "chevron.right")
.font(.appCallout)
} else {
VStack {
ScrollView {
LabelsMasonaryView(labels: labelsViewModel.labels,
selectedLabels: labelsViewModel.selectedLabels,
onLabelTap: onLabelTap)
}.background(Color.appButtonBackground)
.cornerRadius(8)
VStack(spacing: 15) {
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
Button(
action: { labelsViewModel.showCreateLabelModal = true },
label: {
HStack {
Spacer()
Image(systemName: "plus")
Text("Create label")
Spacer()
ScrollView {
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
onLabelTap: onLabelTap)
Button(
action: { labelsViewModel.showCreateLabelModal = true },
label: {
HStack {
Image(systemName: "tag").foregroundColor(.blue)
Text(
labelsViewModel.labelSearchFilter.count > 0 ?
"Create: \"\(labelsViewModel.labelSearchFilter)\" label" :
LocalText.createLabelMessage
).foregroundColor(.blue)
.font(Font.system(size: 14))
Spacer()
}
}
}
).buttonStyle(RoundedRectButtonStyle(color: .blue, textColor: .white))
)
.buttonStyle(PlainButtonStyle())
.padding(10)
}.background(Color.appButtonBackground)
}
}
}
.padding(16)
.padding(viewState == .editingLabels ? 0 : 16)
.background(viewState == .editingLabels ? Color.clear : Color.appButtonBackground)
.frame(maxWidth: .infinity, maxHeight: viewState == .editingLabels ? .infinity : 60)
.background(Color.appButtonBackground)
.cornerRadius(8)
}
@ -248,10 +255,10 @@ public struct ShareExtensionView: View {
}
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
if let selectedIndex = labelsViewModel.selectedLabels.firstIndex(of: label) {
labelsViewModel.selectedLabels.remove(at: selectedIndex)
if labelsViewModel.selectedLabels.contains(label) {
labelsViewModel.selectedLabels.remove(label)
} else {
labelsViewModel.selectedLabels.append(label)
labelsViewModel.selectedLabels.insert(label)
}
if let linkedItem = viewModel.linkedItem {
@ -367,22 +374,6 @@ public struct ShareExtensionView: View {
.padding(.bottom, 12)
} else {
ZStack {
Button(action: {
withAnimation {
if viewState == .editingLabels {
if let linkedItem = self.viewModel.linkedItem {
self.labelsViewModel.selectedLabels = previousLabels ?? []
self.labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID,
dataService: self.viewModel.services.dataService)
}
}
viewState = .mainView
}
}, label: { Text(LocalText.cancelGeneric) })
.frame(maxWidth: .infinity, alignment: .leading)
.opacity(viewState == .viewingHighlight ? 0.0 : 1.0)
// Don't show viewState when viewing the highlight
Text(editingViewTitle).bold()
.frame(maxWidth: .infinity, alignment: .center)
@ -444,7 +435,7 @@ public struct ShareExtensionView: View {
labelsSection
.onTapGesture {
withAnimation {
previousLabels = self.labelsViewModel.selectedLabels
previousLabels = Array(self.labelsViewModel.selectedLabels)
viewState = .editingLabels
}
}
@ -484,95 +475,14 @@ public struct ShareExtensionView: View {
viewModel.savePage(extensionContext: extensionContext)
}
.sheet(isPresented: $labelsViewModel.showCreateLabelModal) {
CreateLabelView(viewModel: labelsViewModel)
CreateLabelView(viewModel: labelsViewModel, newLabelName: labelsViewModel.labelSearchFilter)
}
.alert("Before saving an article select text in Safari to create a highlight on save.",
isPresented: $showHighlightInstructionAlert) {
Button("Ok", role: .cancel) { showHighlightInstructionAlert = false }
}
.environmentObject(viewModel.services.dataService)
.task {
await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService)
}
}
}
struct ApplyLabelsListView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = LabelsViewModel()
let linkedItem: LinkedItem?
func isSelected(_ label: LinkedItemLabel) -> Bool {
viewModel.selectedLabels.contains(where: { $0.id == label.id })
}
var body: some View {
List {
Section(
content: {
ForEach(viewModel.labels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
Button(
action: {
if isSelected(label) {
viewModel.selectedLabels.removeAll(where: { $0.id == label.id })
} else {
viewModel.selectedLabels.append(label)
}
if let linkedItem = linkedItem {
viewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: dataService)
}
},
label: {
HStack {
TextChip(feedItemLabel: label)
Spacer()
if isSelected(label) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.checkmarkBlue)
} else {
Image(systemName: "circle")
.foregroundColor(.appGraySolid)
}
}
.contentShape(Rectangle())
}
)
#if os(iOS)
.listRowSeparator(.hidden)
#endif
.buttonStyle(PlainButtonStyle())
}
},
header: {
Text(LocalText.labelsGeneric)
.font(.appFootnote)
.foregroundColor(.appGrayText)
}
)
#if os(iOS)
.listRowSeparator(.hidden)
#endif
Button(
action: { viewModel.showCreateLabelModal = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text(LocalText.createLabelMessage).foregroundColor(.appGrayTextContrast)
Spacer()
}
}
)
.disabled(viewModel.isLoading)
}
.listStyle(PlainListStyle())
.padding(.vertical, 0)
.task {
await viewModel.loadLabelsFromStore(dataService: dataService)
}
.sheet(isPresented: $viewModel.showCreateLabelModal) {
CreateLabelView(viewModel: viewModel)
}
}.environmentObject(viewModel.services.dataService)
}
}

View file

@ -1,53 +0,0 @@
import SwiftUI
import Views
// TODO: maybe move this into Views package?
// struct IconButtonView: View {
// let title: String
// let systemIconName: String
// let action: () -> Void
//
// var body: some View {
// Button(action: action) {
// VStack(alignment: .center, spacing: 8) {
// Image(systemName: systemIconName)
// .font(.appTitle)
// .foregroundColor(.appYellow48)
// Text(title)
// .font(.appBody)
// .foregroundColor(.appGrayText)
// }
// .frame(
// maxWidth: .infinity,
// maxHeight: .infinity
// )
// .background(Color.appButtonBackground)
// .cornerRadius(8)
// }
// .frame(height: 100)
// }
// }
struct CheckmarkButtonView: View {
let titleText: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(
action: action,
label: {
HStack {
Text(titleText)
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundColor(.appYellow48)
}
}
.padding(.vertical, 8)
}
)
.buttonStyle(RectButtonStyle())
}
}

View file

@ -71,7 +71,7 @@ struct HighlightsListView: View {
}
}
}.sheet(item: $setLabelsHighlight) { highlight in
ApplyLabelsView(mode: .highlight(highlight), onSave: { selectedLabels in
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false, onSave: { selectedLabels in
hasHighlightMutations = true
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,

View file

@ -60,7 +60,7 @@ import Views
loadItems(isRefresh: true)
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(mode: .item(item), onSave: nil)
ApplyLabelsView(mode: .item(item), isSearchFocused: false, onSave: nil)
}
.sheet(item: $viewModel.itemUnderTitleEdit) { item in
LinkedItemMetadataEditView(item: item)
@ -146,7 +146,7 @@ import Views
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
loadItems(isRefresh: true)
loadItems(isRefresh: false)
}
.onReceive(NotificationCenter.default.publisher(for: Notification.Name("PushJSONArticle"))) { notification in
guard let jsonArticle = notification.userInfo?["article"] as? JSONArticle else { return }
@ -191,7 +191,7 @@ import Views
}
.task {
if viewModel.items.isEmpty {
loadItems(isRefresh: true)
loadItems(isRefresh: false)
}
}
}

View file

@ -191,6 +191,7 @@ import Views
group.addTask { await self.loadCurrentViewer(dataService: dataService) }
group.addTask { await self.loadLabels(dataService: dataService) }
group.addTask { await self.syncItems(dataService: dataService) }
group.addTask { await self.updateFetchController(dataService: dataService) }
await group.waitForAll()
}
@ -199,9 +200,6 @@ import Views
await loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
} else {
updateFetchController(dataService: dataService)
Task.detached(priority: .background) {
await self.loadSearchQuery(dataService: dataService, isRefresh: isRefresh)
}
}
isLoading = false

View file

@ -12,9 +12,9 @@ struct ApplyLabelsView: View {
var navTitle: String {
switch self {
case .item, .highlight:
return "Assign Labels"
return "Set Labels"
case .list:
return "Apply Label Filters"
return "Set Labels"
}
}
@ -23,62 +23,68 @@ struct ApplyLabelsView: View {
case .item, .highlight:
return "Save"
case .list:
return "Apply"
return "Done"
}
}
}
let mode: Mode
let isSearchFocused: Bool
let onSave: (([LinkedItemLabel]) -> Void)?
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = LabelsViewModel()
enum ViewState {
case mainView
case editingTitle
case editingLabels
case viewingHighlight
}
func isSelected(_ label: LinkedItemLabel) -> Bool {
viewModel.selectedLabels.contains(where: { $0.id == label.id })
}
var innerBody: some View {
List {
Section {
Button(
action: { viewModel.showCreateLabelModal = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text(LocalText.createLabelMessage).foregroundColor(.appGrayTextContrast)
Spacer()
}
}
)
.disabled(viewModel.isLoading)
}
Section {
ForEach(viewModel.labels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
Button(
action: {
if isSelected(label) {
viewModel.selectedLabels.removeAll(where: { $0.id == label.id })
} else {
viewModel.selectedLabels.append(label)
}
},
label: {
HStack {
TextChip(feedItemLabel: label)
Spacer()
VStack {
SearchBar(searchTerm: $viewModel.labelSearchFilter)
.padding(.vertical, 8)
.padding(.horizontal, 16)
List {
Section {
ForEach(viewModel.labels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
Button(
action: {
if isSelected(label) {
Image(systemName: "checkmark")
viewModel.selectedLabels.remove(label)
} else {
viewModel.selectedLabels.insert(label)
}
},
label: {
HStack {
TextChip(feedItemLabel: label).allowsHitTesting(false)
Spacer()
if isSelected(label) {
Image(systemName: "checkmark")
}
}
}
}
)
#if os(macOS)
.buttonStyle(PlainButtonStyle())
#endif
)
.padding(.vertical, 5)
#if os(macOS)
.buttonStyle(PlainButtonStyle())
#endif
}
createLabelButton
}
}
.listStyle(PlainListStyle())
Spacer()
}
.navigationTitle(mode.navTitle)
#if os(iOS)
@ -100,20 +106,43 @@ struct ApplyLabelsView: View {
}
#endif
.sheet(isPresented: $viewModel.showCreateLabelModal) {
CreateLabelView(viewModel: viewModel)
CreateLabelView(viewModel: viewModel, newLabelName: viewModel.labelSearchFilter)
}
}
var createLabelButton: some View {
Button(
action: { viewModel.showCreateLabelModal = true },
label: {
HStack {
Image(systemName: "tag").foregroundColor(.blue)
Text(
viewModel.labelSearchFilter.count > 0 ?
"Create: \"\(viewModel.labelSearchFilter)\" label" :
LocalText.createLabelMessage
).foregroundColor(.blue)
.font(Font.system(size: 14))
Spacer()
}
}
)
.buttonStyle(PlainButtonStyle())
.disabled(viewModel.isLoading)
.listRowSeparator(.hidden, edges: .bottom)
.padding(.vertical, 10)
}
var saveItemChangesButton: some View {
Button(
action: {
switch mode {
case let .item(feedItem):
viewModel.saveItemLabelChanges(itemID: feedItem.unwrappedID, dataService: dataService)
onSave?(Array(viewModel.selectedLabels))
case .highlight:
onSave?(viewModel.selectedLabels)
onSave?(Array(viewModel.selectedLabels))
case .list:
onSave?(viewModel.selectedLabels)
onSave?(Array(viewModel.selectedLabels))
}
presentationMode.wrappedValue.dismiss()
},
@ -136,10 +165,6 @@ struct ApplyLabelsView: View {
EmptyView()
} else {
innerBody
.searchable(
text: $viewModel.labelSearchFilter,
placement: .navigationBarDrawer(displayMode: .always)
)
}
}
#elseif os(macOS)

View file

@ -22,31 +22,32 @@ struct FilterByLabelsView: View {
var innerBody: some View {
List {
ForEach(viewModel.labels, id: \.self) { label in
HStack {
TextChip(feedItemLabel: label, negated: isNegated(label))
Spacer()
Button(action: {
ForEach(viewModel.labels.applySearchFilter(viewModel.labelSearchFilter), id: \.self) { label in
Button(
action: {
if isSelected(label) {
viewModel.negatedLabels.append(label)
viewModel.selectedLabels.removeAll(where: { $0.id == label.id })
} else if isNegated(label) {
viewModel.negatedLabels.removeAll(where: { $0.id == label.id })
} else {
viewModel.selectedLabels.append(label)
}
}, label: {
if isNegated(label) {
Image(systemName: "circle.slash")
},
label: {
HStack {
TextChip(feedItemLabel: label).allowsHitTesting(false)
Spacer()
if isSelected(label) {
Image(systemName: "checkmark")
}
}
if isSelected(label) {
Image(systemName: "checkmark")
}
})
}
}
)
.padding(.vertical, 5)
#if os(macOS)
.buttonStyle(PlainButtonStyle())
#endif
}
}
.listStyle(.plain)
.listStyle(PlainListStyle())
.navigationTitle("Filter by Label")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)

View file

@ -12,12 +12,8 @@ import Models
import Views
struct LabelsMasonaryView: View {
// var allLabels: [LinkedItemLabel]
// var selectedLabels: [LinkedItemLabel]
var onLabelTap: (LinkedItemLabel, TextChip) -> Void
var iteration = UUID().uuidString
@State private var totalHeight = CGFloat.zero
private var labelItems: [(label: LinkedItemLabel, selected: Bool)]
@ -30,7 +26,9 @@ struct LabelsMasonaryView: View {
let selected = selectedLabels.map { (label: $0, selected: true) }
let unselected = allLabels.filter { !selectedLabels.contains($0) }.map { (label: $0, selected: false) }
labelItems = (selected + unselected).sorted(by: { left, right in
(left.label.name ?? "") < (right.label.name ?? "")
let aTrimmed = left.label.unwrappedName.trimmingCharacters(in: .whitespaces)
let bTrimmed = right.label.unwrappedName.trimmingCharacters(in: .whitespaces)
return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending
})
}
@ -39,8 +37,8 @@ struct LabelsMasonaryView: View {
GeometryReader { geometry in
self.generateContent(in: geometry)
}
}
.frame(height: totalHeight)
}.padding(5)
.frame(height: totalHeight)
}
private func generateContent(in geom: GeometryProxy) -> some View {
@ -50,7 +48,8 @@ struct LabelsMasonaryView: View {
return ZStack(alignment: .topLeading) {
ForEach(self.labelItems, id: \.label.self) { label in
self.item(for: label)
.padding([.horizontal, .vertical], 6)
.padding(.horizontal, 5)
.padding(.vertical, 5)
.alignmentGuide(.leading, computeValue: { dim in
if abs(width - dim.width) > geom.size.width {
width = 0
@ -77,7 +76,7 @@ struct LabelsMasonaryView: View {
}
private func item(for item: (label: LinkedItemLabel, selected: Bool)) -> some View {
let chip = TextChip(feedItemLabel: item.label, negated: false, checked: item.selected) { chip in
let chip = TextChip(feedItemLabel: item.label, negated: false, checked: item.selected, padded: true) { chip in
onLabelTap(item.label, chip)
}
return chip

View file

@ -38,7 +38,7 @@ struct LabelsView: View {
Button(LocalText.cancelGeneric, role: .cancel) { self.labelToRemove = nil }
}
.sheet(isPresented: $viewModel.showCreateLabelModal) {
CreateLabelView(viewModel: viewModel)
CreateLabelView(viewModel: viewModel, newLabelName: viewModel.labelSearchFilter)
}
.task { await viewModel.loadLabels(dataService: dataService, item: nil) }
}
@ -63,7 +63,7 @@ struct LabelsView: View {
Section(header: Text(LocalText.labelsGeneric)) {
ForEach(viewModel.labels, id: \.id) { label in
HStack {
TextChip(feedItemLabel: label)
TextChip(feedItemLabel: label).allowsHitTesting(false)
Spacer()
Button(
action: {
@ -85,9 +85,14 @@ struct CreateLabelView: View {
@EnvironmentObject var dataService: DataService
@ObservedObject var viewModel: LabelsViewModel
@State private var newLabelName = ""
@State private var newLabelName: String
@State private var newLabelColor = Color.clear
init(viewModel: LabelsViewModel, newLabelName: String = "") {
self.viewModel = viewModel
self.newLabelName = newLabelName
}
var shouldDisableCreateButton: Bool {
viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear
}

View file

@ -6,24 +6,37 @@ import Views
@MainActor final class LabelsViewModel: ObservableObject {
@Published var isLoading = false
@Published var selectedLabels = [LinkedItemLabel]()
@Published var unselectedLabels = [LinkedItemLabel]()
@Published var selectedLabels = Set<LinkedItemLabel>()
@Published var unselectedLabels = Set<LinkedItemLabel>()
@Published var labels = [LinkedItemLabel]()
@Published var showCreateLabelModal = false
@Published var labelSearchFilter = ""
func setLabels(_ labels: [LinkedItemLabel]) {
self.labels = labels.sorted { $0.unwrappedName.trimmingCharacters(in: .whitespaces) < $1.unwrappedName.trimmingCharacters(in: .whitespaces) }
self.labels = labels.sorted { left, right in
let aTrimmed = left.unwrappedName.trimmingCharacters(in: .whitespaces)
let bTrimmed = right.unwrappedName.trimmingCharacters(in: .whitespaces)
return aTrimmed.caseInsensitiveCompare(bTrimmed) == .orderedAscending
}
}
func loadLabels(
dataService: DataService,
item: LinkedItem? = nil,
highlight: Highlight? = nil,
initiallySelectedLabels: [LinkedItemLabel]? = nil
) async {
isLoading = true
let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? highlight?.sortedLabels ?? []
await loadLabelsFromStore(dataService: dataService)
for label in labels {
if selLabels.contains(label) {
selectedLabels.insert(label)
} else {
unselectedLabels.insert(label)
}
}
Task.detached(priority: .userInitiated) {
if let labelIDs = try? await dataService.labels() {
@ -31,12 +44,11 @@ import Views
dataService.viewContext.performAndWait {
self.setLabels(labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel })
}
let selLabels = initiallySelectedLabels ?? item?.sortedLabels ?? []
for label in self.labels {
if selLabels.contains(label) {
self.selectedLabels.append(label)
self.selectedLabels.insert(label)
} else {
self.unselectedLabels.append(label)
self.unselectedLabels.insert(label)
}
}
}
@ -46,29 +58,6 @@ import Views
isLoading = false
}
func loadLabels(
dataService: DataService,
highlight: Highlight
) async {
isLoading = true
if let labelIDs = try? await dataService.labels() {
dataService.viewContext.performAndWait {
setLabels(labelIDs.compactMap { dataService.viewContext.object(with: $0) as? LinkedItemLabel })
}
let selLabels = highlight.labels ?? []
for label in labels {
if selLabels.contains(label) {
selectedLabels.append(label)
} else {
unselectedLabels.append(label)
}
}
}
isLoading = false
}
func loadLabelsFromStore(dataService: DataService) async {
let fetchRequest: NSFetchRequest<Models.LinkedItemLabel> = LinkedItemLabel.fetchRequest()
@ -76,12 +65,8 @@ import Views
try? fetchRequest.execute()
}
if fetchedLabels?.count == 0 {
await fetchLabelsFromNetwork(dataService: dataService)
} else {
setLabels(fetchedLabels ?? [])
unselectedLabels = fetchedLabels ?? []
}
setLabels(fetchedLabels ?? [])
unselectedLabels = Set(fetchedLabels ?? [])
}
func fetchLabelsFromNetwork(dataService: DataService) async {
@ -93,7 +78,7 @@ import Views
}
setLabels(fetchedLabels)
unselectedLabels = fetchedLabels
unselectedLabels = Set(fetchedLabels)
}
func createLabel(dataService: DataService, name: String, color: Color, description: String?) {
@ -110,7 +95,7 @@ import Views
if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel {
labels.insert(label, at: 0)
unselectedLabels.insert(label, at: 0)
selectedLabels.insert(label)
}
isLoading = false
@ -120,8 +105,6 @@ import Views
func deleteLabel(dataService: DataService, labelID: String, name: String) {
dataService.removeLabel(labelID: labelID, name: name)
labels.removeAll { $0.name == name }
selectedLabels.removeAll { $0.name == name }
unselectedLabels.removeAll { $0.name == name }
}
func saveItemLabelChanges(itemID: String, dataService: DataService) {
@ -131,14 +114,4 @@ import Views
func saveHighlightLabelChanges(highlightID: String, dataService: DataService) {
dataService.setLabelsForHighlight(highlightID: highlightID, labelIDs: selectedLabels.map(\.unwrappedID))
}
func addLabelToItem(_ label: LinkedItemLabel) {
selectedLabels.insert(label, at: 0)
unselectedLabels.removeAll { $0.name == label.name }
}
func removeLabelFromItem(_ label: LinkedItemLabel) {
unselectedLabels.insert(label, at: 0)
selectedLabels.removeAll { $0.name == label.name }
}
}

View file

@ -100,6 +100,7 @@ struct WebReader: PlatformViewRepresentable {
(webView as? OmnivoreWebView)?.updateTextContrast()
(webView as? OmnivoreWebView)?.updateMaxWidthPercentage()
(webView as? OmnivoreWebView)?.updateLineHeight()
(webView as? OmnivoreWebView)?.updateLabels(labelsJSON: item.labelsJSONString)
}
if showNavBarActionID != context.coordinator.previousShowNavBarActionID {

View file

@ -181,7 +181,7 @@ struct WebReaderContainerView: View {
}
func menuItems(for item: LinkedItem) -> some View {
let hasLabels = item.labels?.count == 0
let hasLabels = item.labels?.count != 0
return Group {
Button(
action: { showHighlightsView = true },
@ -295,7 +295,11 @@ struct WebReaderContainerView: View {
Button(LocalText.cancelGeneric, role: .cancel, action: {})
}
.sheet(isPresented: $showLabelsModal) {
ApplyLabelsView(mode: .item(item), onSave: { _ in showLabelsModal = false })
ApplyLabelsView(mode: .item(item), isSearchFocused: false, onSave: { labels in
showLabelsModal = false
item.labels = NSSet(array: labels)
readerSettingsChangedTransactionID = UUID()
})
}
.sheet(isPresented: $showTitleEdit) {
LinkedItemMetadataEditView(item: item)
@ -410,7 +414,7 @@ struct WebReaderContainerView: View {
}
.sheet(isPresented: $showHighlightLabelsModal) {
if let highlight = Highlight.lookup(byID: self.annotation, inContext: self.dataService.viewContext) {
ApplyLabelsView(mode: .highlight(highlight)) { selectedLabels in
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false) { selectedLabels in
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labelIDs: selectedLabels.map(\.unwrappedID),
dataService: dataService)

View file

@ -19,6 +19,12 @@ public extension Highlight {
return highlight
}
var sortedLabels: [LinkedItemLabel] {
labels.asArray(of: LinkedItemLabel.self).sorted {
($0.name ?? "").lowercased() < ($1.name ?? "").lowercased()
}
}
func update(
inContext context: NSManagedObjectContext,
newAnnotation: String

View file

@ -15,7 +15,30 @@
]
if let heightRatio = heightRatio {
constraints.append(child.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: heightRatio))
let constraint = child.view.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: heightRatio)
constraints.append(constraint)
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: OperationQueue.main) { _ in
UIView.animate(withDuration: 0.2) {
if let parent = self.parent, let frame = constraint.firstItem?.frame {
constraint.constant = parent.view.frame.height - frame.height - 10
}
child.view.setNeedsLayout()
child.view.layoutIfNeeded()
}
}
NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: OperationQueue.main) { _ in
UIView.animate(withDuration: 0.2) {
constraint.constant = 0
child.view.setNeedsLayout()
child.view.layoutIfNeeded()
}
}
} else {
constraints.append(child.view.topAnchor.constraint(equalTo: view.topAnchor))
}
@ -24,6 +47,23 @@
child.didMove(toParent: self)
}
//
// @objc func keyboardWillShow(notification: Notification) {
// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
// if self.view.frame.origin.y == 0{
// self.view.frame.origin.y -= keyboardSize.height
// }
// }
//
// }
//
// @objc func keyboardWillHide(notification: Notification) {
// if let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue {
// if self.view.frame.origin.y != 0 {
// self.view.frame.origin.y += keyboardSize.height
// }
// }
// }
}
#endif

View file

@ -105,6 +105,14 @@ public final class OmnivoreWebView: WKWebView {
}
}
public func updateLabels(labelsJSON: String) {
do {
try dispatchEvent(.updateLabels(labels: labelsJSON))
} catch {
showErrorInSnackbar("Error updating labels")
}
}
public func shareOriginalItem() {
do {
try dispatchEvent(.share)
@ -379,6 +387,7 @@ public enum WebViewDispatchEvent {
case copyHighlight
case dismissHighlight
case speakingSection(anchorIdx: String)
case updateLabels(labels: String)
var script: String {
get throws {
@ -421,6 +430,8 @@ public enum WebViewDispatchEvent {
return "dismissHighlight"
case .speakingSection:
return "speakingSection"
case .updateLabels:
return "updateLabels"
}
}
@ -441,6 +452,8 @@ public enum WebViewDispatchEvent {
return "event.isDark = '\(isDark)';"
case let .updateFontFamily(family: family):
return "event.fontFamily = '\(family)';"
case let .updateLabels(labels):
return "event.labels = \(labels);"
case let .saveAnnotation(annotation: annotation):
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(annotation) {

View file

@ -2,12 +2,12 @@
"colors" : [
{
"color" : {
"color-space" : "srgb",
"color-space" : "display-p3",
"components" : {
"alpha" : "1.000",
"blue" : "0x34",
"green" : "0xD2",
"red" : "0xFF"
"blue" : "0x57",
"green" : "0xD4",
"red" : "0xF8"
}
},
"idiom" : "universal"

View file

@ -66,8 +66,8 @@ public extension Font {
}
/// 12pt, Inter-Regular
static var appCaptionBold: Font {
.customFont(InterFont.bold.rawValue, size: 12, relativeTo: .caption)
static var appCaptionMedium: Font {
.customFont(InterFont.medium.rawValue, size: 12, relativeTo: .caption)
}
/// 11pt, Inter-Regular

File diff suppressed because one or more lines are too long

View file

@ -10,8 +10,8 @@
"labels.generic" = "Labels";
"emails.generic" = "Emails";
"subscriptions.generic" = "Text to Speech";
"textToSpeech.generic" = "Subscriptions";
"subscriptions.generic" = "Subscriptions";
"textToSpeech.generic" = "Text to Speech";
"privacy.policy.generic" = "Privacy Policy";
"termsAndConditions.generic" = "Terms and Conditions";
"feedback.generic" = "Feedback";

View file

@ -1,62 +1,59 @@
import SwiftUI
public struct SearchBar: View {
private let horizontalPadding: Double
@Binding var searchTerm: String
@FocusState private var isFocused: Bool
public init(
searchTerm: Binding<String>,
horizontalPadding: Double = 10
searchTerm: Binding<String>
) {
self._searchTerm = searchTerm
self.horizontalPadding = horizontalPadding
}
public var body: some View {
HStack(spacing: 0) {
TextField("Search", text: $searchTerm)
.padding(7)
.padding(.horizontal, 25)
.background(Color.systemGray6)
.cornerRadius(8)
.frame(height: 36)
.frame(maxWidth: .infinity)
.padding(.leading, 28)
.padding(.trailing, 28)
.focused($isFocused)
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 10)
.resizable()
.frame(width: 14, height: 14)
.foregroundColor(.appGrayText)
.padding(.leading, 8)
if self.searchTerm != "" {
Button(
action: {
self.searchTerm = ""
},
label: {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.gray)
.padding(.trailing, 8)
}
)
}
Spacer()
}
)
.padding(.horizontal, horizontalPadding)
if isFocused {
Button(
action: {
self.searchTerm = ""
self.isFocused = false
},
label: {
Text(LocalText.cancelGeneric)
Image(systemName: "multiply.circle.fill")
.foregroundColor(.gray)
}
)
.padding(.trailing, 10)
.padding(.trailing, 8)
.transition(.move(edge: .trailing))
}
}
.background(Color.appButtonBackground)
.cornerRadius(8)
.frame(height: 36)
.onChange(of: isFocused) { isFocused in
if !isFocused {
searchTerm = ""
}
}
.onTapGesture {
isFocused = true
}
}
}

View file

@ -6,6 +6,7 @@ public struct TextChip: View {
@Environment(\.colorScheme) var colorScheme
let checked: Bool
let padded: Bool
var onTap: ((TextChip) -> Void)?
public init(text: String, color: Color, negated: Bool = false) {
@ -13,6 +14,7 @@ public struct TextChip: View {
self.color = color
self.negated = negated
self.checked = false
self.padded = false
}
public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false) {
@ -22,9 +24,10 @@ public struct TextChip: View {
self.color = color
self.negated = negated
self.checked = false
self.padded = false
}
public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false, checked: Bool = false, onTap: ((TextChip) -> Void)?) {
public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false, checked: Bool = false, padded: Bool = false, onTap: ((TextChip) -> Void)?) {
guard let color = Color(hex: feedItemLabel.color ?? "") else {
return nil
}
@ -34,6 +37,7 @@ public struct TextChip: View {
self.negated = negated
self.onTap = onTap
self.checked = checked
self.padded = padded
}
public let text: String
@ -45,37 +49,28 @@ public struct TextChip: View {
return .white
}
if colorScheme == .light {
return luminance > 0.5 ? .black : .white
}
if luminance > 0.2 {
return color
}
// lighten the color by 20%
return Color.lighten(color: color, by: 20)
return luminance > 0.5 ? .black : .white
}
var backgroundColor: Color {
color.opacity(colorScheme == .dark ? 0.2 : 1)
color.opacity(0.9)
}
var checkedBorderColor: Color {
colorScheme == .dark ? Color.white : Color.black
}
var borderColor: Color {
if colorScheme == .dark {
return textColor
} else {
return color.opacity(0.7)
}
checked ? checkedBorderColor : Color.clear
}
public var body: some View {
ZStack(alignment: .topTrailing) {
Text(text)
.strikethrough(color: negated ? textColor : .clear)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.font(.appCaptionBold)
.padding(.horizontal, padded ? 10 : 8)
.padding(.vertical, padded ? 8 : 5)
.font(.appCaptionMedium)
.foregroundColor(textColor)
.lineLimit(1)
.background(
@ -84,18 +79,9 @@ public struct TextChip: View {
)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(borderColor, lineWidth: 1)
.stroke(borderColor, lineWidth: 2)
)
.padding(1)
.overlay(alignment: .topTrailing) {
if checked {
Image(systemName: "checkmark.circle.fill")
.font(.appBody)
.symbolVariant(.circle.fill)
.foregroundStyle(Color.appBackground, Color.appGreenSuccess)
.padding([.top, .trailing], -6)
}
}
}.onTapGesture {
if let onTap = onTap {
onTap(self)

View file

@ -50,8 +50,14 @@ const mutation = async (name, input) => {
}
const App = () => {
const [labels, setLabels] = React.useState(window.omnivoreArticle.labels)
applyStoredTheme(false)
document.addEventListener('updateLabels', (event) => {
console.log("updating labels: ", event.labels)
setLabels(event.labels)
})
return (
<>
<Box
@ -68,7 +74,7 @@ const App = () => {
>
<ArticleContainer
article={window.omnivoreArticle}
labels={window.omnivoreArticle.labels}
labels={labels}
isAppleAppEmbed={true}
highlightBarDisabled={!window.enableHighlightBar}
highlightsBaseURL="https://example.com"

View file

@ -171,7 +171,7 @@ Readability.prototype = {
// Readability-readerable.js. Please keep both copies in sync.
articleNegativeLookBehindCandidates: /breadcrumbs|breadcrumb|utils|trilist/i,
articleNegativeLookAheadCandidates: /outstream(.?)_|sub(.?)_|m_|omeda-promo-|in-article-advert|block-ad-.*/i,
unlikelyCandidates: /\bad\b|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|post-head|post-tag|li-date|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder|back-to-top|show-up-next|onward-journey|topic-tracker|list-nav|block-ad-entity|adSpecs|gift-article-button|modal-title|in-story-masthead|share-tools|standard-dock|expanded-dock|margins-h|subscribe-dialog|icon|bumped|dvz-social-media-buttons/i,
unlikelyCandidates: /\bad\b|ai2html|banner|breadcrumbs|breadcrumb|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager(?!ow)|popup|yom-remote|copyright|keywords|outline|infinite-list|beta|recirculation|site-index|hide-for-print|post-end-share-cta|post-end-cta-full|post-footer|post-head|post-tag|li-date|main-navigation|programtic-ads|outstream_article|hfeed|comment-holder|back-to-top|show-up-next|onward-journey|topic-tracker|list-nav|block-ad-entity|adSpecs|gift-article-button|modal-title|in-story-masthead|share-tools|standard-dock|expanded-dock|margins-h|subscribe-dialog|icon|bumped|dvz-social-media-buttons|post-toc|mobile-menu|mobile-navbar/i,
// okMaybeItsACandidate: /and|article(?!-breadcrumb)|body|column|content|main|shadow|post-header/i,
get okMaybeItsACandidate() {
return new RegExp(`and|(?<!${this.articleNegativeLookAheadCandidates.source})article(?!-(${this.articleNegativeLookBehindCandidates.source}))|body|column|content|^(?!main-navigation|main-header)main|shadow|post-header|hfeed site|blog-posts hfeed|container-banners|menu-opacity|header-with-anchor-widget`, 'i')