diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift index 9fb741e57..b3f2fa632 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/ShareExtensionViewModel.swift @@ -1,12 +1,13 @@ import CoreData import Models +import Services import SwiftUI import Utils import Views public class ShareExtensionViewModel: ObservableObject { @Published public var status: ShareExtensionStatus = .processing - @Published public var title: String? + @Published public var title: String = "" @Published public var url: String? @Published public var iconURL: String? @Published public var linkedItem: LinkedItem? @@ -42,6 +43,22 @@ public class ShareExtensionViewModel: ObservableObject { } } + func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) { + dataService.archiveLink(objectID: objectID, archived: archived) + } + + func removeLink(dataService: DataService, objectID: NSManagedObjectID) { + dataService.removeLink(objectID: objectID) + } + + func submitTitleEdit(dataService: DataService, itemID: String, title: String, description: String) { + dataService.updateLinkedItemTitleAndDescription( + itemID: itemID, + title: title, + description: description + ) + } + #if os(iOS) func queueSaveOperation(_ payload: PageScrapePayload) { ProcessInfo().performExpiringActivity(withReason: "app.omnivore.SaveActivity") { [self] expiring in @@ -72,7 +89,7 @@ public class ShareExtensionViewModel: ObservableObject { switch payload.contentType { case let .html(html: _, title: title, iconURL: iconURL): - self.title = title + self.title = title ?? "" self.iconURL = iconURL self.url = hostname case .none: diff --git a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift index a4d58cc84..f2363e441 100644 --- a/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift +++ b/apple/OmnivoreKit/Sources/App/AppExtensions/Share/Views/ShareExtensionView.swift @@ -6,10 +6,22 @@ import Views public struct ShareExtensionView: View { let extensionContext: NSExtensionContext? + @EnvironmentObject var dataService: DataService + @StateObject var labelsViewModel = LabelsViewModel() @StateObject private var viewModel = ShareExtensionViewModel() @State var reminderTime: ReminderTime? @State var hideUntilReminded = false + @State var editingTitle = false + @State var editingLabels = false + @State var previousLabels: [LinkedItemLabel]? + @State var messageText: String? + + enum FocusField: Hashable { + case titleEditor + } + + @FocusState private var focusedField: FocusField? private func handleReminderTimeSelection(_ selectedTime: ReminderTime) { if selectedTime == reminderTime { @@ -134,61 +146,332 @@ public struct ShareExtensionView: View { .cornerRadius(8) } - public var body: some View { - VStack(alignment: .leading) { - Text(titleText) - .foregroundColor(.appGrayTextContrast) - .font(Font.system(size: 17, weight: .semibold)) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 23) - .padding(.bottom, 12) + var isSynced: Bool { + switch viewModel.status { + case .synced: + return true + default: + return false + } + } - Rectangle() - .foregroundColor(.appGrayText) - .frame(maxWidth: .infinity, maxHeight: 1) - .opacity(0.06) - .padding(.top, 0) - .padding(.bottom, 18) + var titleBar: some View { + HStack { + Spacer() - previewCard - .padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) + Image(systemName: "checkmark.circle") + .frame(width: 15, height: 15) + .foregroundColor(.appGreenSuccess) + .opacity(isSynced ? 1.0 : 0.0) - if let item = viewModel.linkedItem { - ApplyLabelsListView(linkedItem: item) + Text(messageText ?? titleText) + .font(.appSubheadline) + .foregroundColor(isSynced ? .appGreenSuccess : .appGrayText) + + Spacer() + } + } + + public var titleBox: some View { + VStack(alignment: .trailing) { + Button(action: {}, label: { + Text("Edit") + .font(.appFootnote) + .padding(.trailing, 8) + .onTapGesture { + editingTitle = true + } + }) + // Disabling this button temporarily + .disabled(editingTitle) + .opacity(editingTitle ? 0.0 : 1.0) + + VStack(alignment: .leading) { + if !editingTitle { + Text(self.viewModel.title) + .font(.appSubheadline) + .foregroundColor(.appGrayTextContrast) + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer() + + Text(self.viewModel.url ?? "") + .font(.appFootnote) + .foregroundColor(.appGrayText) + .frame(maxWidth: .infinity, alignment: .leading) + } else {} + } + .frame(maxWidth: .infinity, maxHeight: 60) + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.appGrayBorder, lineWidth: 1) + ) + } + } + + var labelsSection: some View { + HStack { + if !editingLabels { + ZStack { + Circle() + .foregroundColor(Color.blue) + .frame(width: 34, height: 34) + + Image(systemName: "tag") + .font(.appCallout) + .frame(width: 34, height: 34) + } + .padding(.trailing, 8) + + VStack { + Text("Labels") + .font(.appSubheadline) + .foregroundColor(Color.appGrayTextContrast) + .frame(maxWidth: .infinity, alignment: .leading) + + let labelCount = labelsViewModel.selectedLabels.count + Text(labelCount > 0 ? + "\(labelCount) label\(labelCount > 1 ? "s" : "") selected" + : "Add labels to your saved link") + .font(.appFootnote) + .foregroundColor(Color.appGrayText) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.appCallout) } else { + ScrollView { + LabelsMasonaryView(labels: labelsViewModel.labels, + selectedLabels: labelsViewModel.selectedLabels, + onLabelTap: onLabelTap) + }.background(Color.appButtonBackground) + .cornerRadius(8) + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: self.editingLabels ? .infinity : 60) + .background(Color.appButtonBackground) + .cornerRadius(8) + } + + func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) { + if let selectedIndex = labelsViewModel.selectedLabels.firstIndex(of: label) { + labelsViewModel.selectedLabels.remove(at: selectedIndex) + } else { + labelsViewModel.selectedLabels.append(label) + } + + if let linkedItem = viewModel.linkedItem { + labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, dataService: viewModel.services.dataService) + } + } + + var primaryButtons: some View { + HStack { + Button( + action: { viewModel.handleReadNowAction(extensionContext: extensionContext) }, + label: { + Label("Read Now", systemImage: "book") + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + ) + .foregroundColor(.white) + .background(Color.appButtonBackground) + .frame(height: 52) + .cornerRadius(8) + + Spacer(minLength: 8) + + Button( + action: { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + }, + label: { + Label("Read Later", systemImage: "text.book.closed.fill") + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + ) + .foregroundColor(.black) + .background(Color.appBackground) + .frame(height: 52) + .cornerRadius(8) + } + } + + var moreActionsMenu: some View { + Menu { + Button( + action: {}, + label: { + Button(action: {}, label: { Label("Dismiss", systemImage: "arrow.down.to.line") }) + } + ) + Button(action: { + if let linkedItem = self.viewModel.linkedItem { + self.viewModel.setLinkArchived(dataService: self.viewModel.services.dataService, + objectID: linkedItem.objectID, + archived: true) + messageText = "Link Archived" + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + } + }, label: { + Label( + "Archive", + systemImage: "archivebox" + ) + }) + Button( + action: { + if let linkedItem = self.viewModel.linkedItem { + self.viewModel.removeLink(dataService: self.viewModel.services.dataService, objectID: linkedItem.objectID) + messageText = "Link Removed" + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) { + extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + } + } + }, + label: { + Label("Remove", systemImage: "trash") + } + ) + } label: { + Text("More Actions") + .font(.appFootnote) + .foregroundColor(Color.blue) + .frame(maxWidth: .infinity) + .padding(8) + .padding(.bottom, 8) + } + } + + public var body: some View { + VStack(alignment: .center) { + Capsule() + .fill(.gray) + .frame(width: 60, height: 4) + .padding(.top, 10) + + if !editingLabels, !editingTitle { + titleBar + .padding(.top, 10) + .padding(.bottom, 12) + } else { + ZStack { + Button(action: { + withAnimation { + if editingLabels { + if let linkedItem = self.viewModel.linkedItem { + self.labelsViewModel.selectedLabels = previousLabels ?? [] + self.labelsViewModel.saveItemLabelChanges(itemID: linkedItem.unwrappedID, + dataService: self.viewModel.services.dataService) + } + } + editingTitle = false + editingLabels = false + } + }, label: { Text("Cancel") }) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(editingTitle ? "Edit Title" : "Labels").bold() + .frame(maxWidth: .infinity, alignment: .center) + + Button(action: { + withAnimation { + editingTitle = false + editingLabels = false + + if editingTitle { + if let linkedItem = self.viewModel.linkedItem { + viewModel.submitTitleEdit(dataService: self.viewModel.services.dataService, + itemID: linkedItem.unwrappedID, + title: self.viewModel.title, + description: linkedItem.description) + } + } + } + }, label: { Text("Done").bold() }) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(8) + .padding(.bottom, 4) + } + + if !editingLabels, !editingTitle { + titleBox + } + + if editingTitle { + ScrollView(showsIndicators: false) { + VStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + TextEditor(text: $viewModel.title) + .lineSpacing(6) + .accentColor(.appGraySolid) + .foregroundColor(.appGrayTextContrast) + .font(.appSubheadline) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.appGrayBorder, lineWidth: 1) + .background(RoundedRectangle(cornerRadius: 8).fill(Color.systemBackground)) + ) + .frame(height: 100) + .focused($focusedField, equals: .titleEditor) + .task { + self.focusedField = .titleEditor + } + } + } + .padding(8) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + Spacer() } - HStack { - Button( - action: { viewModel.handleReadNowAction(extensionContext: extensionContext) }, - label: { Text("Read Now").frame(maxWidth: .infinity) } - ) - .buttonStyle(RoundedRectButtonStyle()) - - Button( - action: { - extensionContext?.completeRequest(returningItems: [], completionHandler: nil) - }, - label: { - Text("Read Later") - .frame(maxWidth: .infinity) + if !editingTitle { + labelsSection + .padding(.top, 12) + .onTapGesture { + withAnimation { + previousLabels = self.labelsViewModel.selectedLabels + editingLabels = true + } } - ) - .buttonStyle(RoundedRectButtonStyle()) } - .padding(.horizontal) - .padding(.bottom) + + Spacer() + + if !editingLabels, !editingTitle { + Divider() + .padding(.bottom, 20) + + primaryButtons + + moreActionsMenu + } } .frame( maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading ) + .padding(.horizontal, 16) .onAppear { viewModel.savePage(extensionContext: extensionContext) } .environmentObject(viewModel.services.dataService) + .task { + await labelsViewModel.loadLabelsFromStore(dataService: viewModel.services.dataService) + } } } diff --git a/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift new file mode 100644 index 000000000..0d820768e --- /dev/null +++ b/apple/OmnivoreKit/Sources/App/Views/Labels/LabelsMasonaryView.swift @@ -0,0 +1,99 @@ +// +// LabelsMasonaryView.swift +// +// +// Created by Jackson Harper on 11/9/22. +// + +import Foundation +import SwiftUI + +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)] + + init(labels allLabels: [LinkedItemLabel], + selectedLabels: [LinkedItemLabel], + onLabelTap: @escaping (LinkedItemLabel, TextChip) -> Void) + { + self.onLabelTap = onLabelTap + + 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 ?? "") + }) + } + + var body: some View { + VStack { + GeometryReader { geometry in + self.generateContent(in: geometry) + } + } + .frame(height: totalHeight) + } + + private func generateContent(in geom: GeometryProxy) -> some View { + var width = CGFloat.zero + var height = CGFloat.zero + + return ZStack(alignment: .topLeading) { + ForEach(self.labelItems, id: \.label.self) { label in + self.item(for: label) + .padding([.horizontal, .vertical], 4) + .alignmentGuide(.leading, computeValue: { dim in + if abs(width - dim.width) > geom.size.width { + width = 0 + height -= dim.height + } + let result = width + if label == self.labelItems.last! { + width = 0 // last item + } else { + width -= dim.width + } + return result + }) + .alignmentGuide(.top, computeValue: { _ in + let result = height + if label == self.labelItems.last! { + height = 0 // last item + } + return result + }) + } + } + .background(viewHeightReader($totalHeight)) + } + + private func item(for item: (label: LinkedItemLabel, selected: Bool)) -> some View { + if item.selected { + print(" -- SELECTED LABEL", item.label.name) + } + print("GETTING ITERATION", iteration) + let chip = TextChip(feedItemLabel: item.label, negated: false, checked: item.selected) { chip in + onLabelTap(item.label, chip) + } + return chip + } + + private func viewHeightReader(_ binding: Binding) -> some View { + GeometryReader { geometry -> Color in + let rect = geometry.frame(in: .local) + DispatchQueue.main.async { + binding.wrappedValue = rect.size.height + } + return .clear + } + } +} diff --git a/apple/OmnivoreKit/Sources/Services/AudioSession/PrefetchSpeechItemOperation.swift b/apple/OmnivoreKit/Sources/Services/AudioSession/PrefetchSpeechItemOperation.swift index 85351c09a..cfbc8ea50 100644 --- a/apple/OmnivoreKit/Sources/Services/AudioSession/PrefetchSpeechItemOperation.swift +++ b/apple/OmnivoreKit/Sources/Services/AudioSession/PrefetchSpeechItemOperation.swift @@ -8,7 +8,6 @@ import Foundation import Models import Utils -import Views final class PrefetchSpeechItemOperation: Operation, URLSessionDelegate { let speechItem: SpeechItem diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift index a7aaa4f53..cdb0cf5a5 100644 --- a/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.swift @@ -3,6 +3,7 @@ import SwiftUI public extension Color { static var appBackground: Color { Color("_background", bundle: .module) } static var appDeepBackground: Color { Color("_deepBackground", bundle: .module) } + static var appGreenSuccess: Color { Color("_appGreenSuccess", bundle: .module) } // GrayScale -- adapted from Radix Colors static var appGrayBorder: Color { Color("_grayBorder", bundle: .module) } diff --git a/apple/OmnivoreKit/Sources/Views/Colors/Colors.xcassets/_appGreenSuccess.colorset/Contents.json b/apple/OmnivoreKit/Sources/Views/Colors/Colors.xcassets/_appGreenSuccess.colorset/Contents.json new file mode 100644 index 000000000..6f5bd0010 --- /dev/null +++ b/apple/OmnivoreKit/Sources/Views/Colors/Colors.xcassets/_appGreenSuccess.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.294", + "green" : "0.843", + "red" : "0.196" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.294", + "green" : "0.843", + "red" : "0.196" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/OmnivoreKit/Sources/Views/TextChip.swift b/apple/OmnivoreKit/Sources/Views/TextChip.swift index 845a0810f..61858a519 100644 --- a/apple/OmnivoreKit/Sources/Views/TextChip.swift +++ b/apple/OmnivoreKit/Sources/Views/TextChip.swift @@ -5,10 +5,14 @@ import Utils public struct TextChip: View { @Environment(\.colorScheme) var colorScheme + let checked: Bool + var onTap: ((TextChip) -> Void)? + public init(text: String, color: Color, negated: Bool = false) { self.text = text self.color = color self.negated = negated + self.checked = false } public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false) { @@ -17,9 +21,24 @@ public struct TextChip: View { self.text = feedItemLabel.name ?? "" self.color = color self.negated = negated + self.checked = false } - let text: String + public init?(feedItemLabel: LinkedItemLabel, negated: Bool = false, checked: Bool = false, onTap: ((TextChip) -> Void)?) { + guard let color = Color(hex: feedItemLabel.color ?? "") else { + print("RETURNING NUL!") + return nil + } + + print("TEXT CHIP", feedItemLabel.name, checked) + self.text = feedItemLabel.name ?? "" + self.color = color + self.negated = negated + self.onTap = onTap + self.checked = checked + } + + public let text: String let color: Color let negated: Bool @@ -53,16 +72,31 @@ public struct TextChip: View { } public var body: some View { - Text(text) - .strikethrough(color: negated ? textColor : .clear) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .font(.appCaptionBold) - .foregroundColor(textColor) - .lineLimit(1) - .background(Capsule().fill(backgroundColor)) - .overlay(Capsule().stroke(borderColor, lineWidth: 1)) - .padding(1) + ZStack(alignment: .topTrailing) { + Text(text) + .strikethrough(color: negated ? textColor : .clear) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .font(.appCaptionBold) + .foregroundColor(textColor) + .lineLimit(1) + .background(Capsule().fill(backgroundColor)) + .overlay(Capsule().stroke(borderColor, lineWidth: 1)) + .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) + } + } } }