Update the share extension view to newer design

This commit is contained in:
Jackson Harper
2022-11-10 14:23:17 +08:00
parent 878cb51533
commit a0f17185d8
7 changed files with 521 additions and 50 deletions

View File

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

View File

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

View File

@ -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<CGFloat>) -> some View {
GeometryReader { geometry -> Color in
let rect = geometry.frame(in: .local)
DispatchQueue.main.async {
binding.wrappedValue = rect.size.height
}
return .clear
}
}
}

View File

@ -8,7 +8,6 @@
import Foundation
import Models
import Utils
import Views
final class PrefetchSpeechItemOperation: Operation, URLSessionDelegate {
let speechItem: SpeechItem

View File

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

View File

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

View File

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