mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Improvements to label editor
This commit is contained in:
parent
57c481ccfd
commit
3be2db1113
7 changed files with 274 additions and 74 deletions
|
|
@ -33,11 +33,13 @@ public struct EditLabelsSheet: View {
|
|||
UITextView.appearance().textContainerInset = UIEdgeInsets(top: 5, left: 2, bottom: 5, right: 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func onLabelTap(label: LinkedItemLabel, textChip _: TextChip) {
|
||||
if labelsViewModel.selectedLabels.contains(label) {
|
||||
labelsViewModel.selectedLabels.remove(label)
|
||||
if let idx = labelsViewModel.selectedLabels.firstIndex(of: label) {
|
||||
labelsViewModel.selectedLabels.remove(at: idx)
|
||||
} else {
|
||||
labelsViewModel.selectedLabels.insert(label)
|
||||
labelsViewModel.labelSearchFilter = ""
|
||||
labelsViewModel.selectedLabels.append(label)
|
||||
}
|
||||
|
||||
if let linkedItem = viewModel.linkedItem {
|
||||
|
|
@ -47,13 +49,19 @@ public struct EditLabelsSheet: View {
|
|||
|
||||
var content: some View {
|
||||
VStack(spacing: 15) {
|
||||
SearchBar(searchTerm: $labelsViewModel.labelSearchFilter)
|
||||
LabelsEntryView(
|
||||
searchTerm: $labelsViewModel.labelSearchFilter,
|
||||
viewModel: labelsViewModel
|
||||
)
|
||||
|
||||
// swiftlint:disable line_length
|
||||
ScrollView {
|
||||
LabelsMasonaryView(labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
onLabelTap: onLabelTap)
|
||||
LabelsMasonaryView(
|
||||
labels: labelsViewModel.labels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
selectedLabels: labelsViewModel.selectedLabels.applySearchFilter(labelsViewModel.labelSearchFilter),
|
||||
onLabelTap: onLabelTap
|
||||
)
|
||||
|
||||
Button(
|
||||
action: { labelsViewModel.showCreateLabelModal = true },
|
||||
label: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
|
||||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
|
|
@ -49,9 +50,17 @@ struct ApplyLabelsView: View {
|
|||
|
||||
var innerBody: some View {
|
||||
VStack {
|
||||
SearchBar(searchTerm: $viewModel.labelSearchFilter)
|
||||
if !viewModel.labels.isEmpty {
|
||||
LabelsEntryView(
|
||||
searchTerm: $viewModel.labelSearchFilter,
|
||||
viewModel: viewModel
|
||||
)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
if viewModel.labelSearchFilter.count >= 63 {
|
||||
Text("The maximum length of a label is 64 chars.").foregroundColor(Color.red).font(.footnote)
|
||||
}
|
||||
|
||||
List {
|
||||
Section {
|
||||
|
|
@ -59,9 +68,12 @@ struct ApplyLabelsView: View {
|
|||
Button(
|
||||
action: {
|
||||
if isSelected(label) {
|
||||
viewModel.selectedLabels.remove(label)
|
||||
if let idx = viewModel.selectedLabels.firstIndex(of: label) {
|
||||
viewModel.selectedLabels.remove(at: idx)
|
||||
}
|
||||
} else {
|
||||
viewModel.selectedLabels.insert(label)
|
||||
viewModel.labelSearchFilter = ""
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
},
|
||||
label: {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import CoreData
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor public final class LabelsViewModel: ObservableObject {
|
||||
let labelNameMaxLength = 64
|
||||
|
||||
@Published var isLoading = false
|
||||
@Published var selectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var selectedLabels = [LinkedItemLabel]()
|
||||
@Published var unselectedLabels = Set<LinkedItemLabel>()
|
||||
@Published var labels = [LinkedItemLabel]()
|
||||
@Published var showCreateLabelModal = false
|
||||
|
|
@ -36,7 +35,7 @@ import Views
|
|||
await loadLabelsFromStore(dataService: dataService)
|
||||
for label in labels {
|
||||
if selLabels.contains(label) {
|
||||
selectedLabels.insert(label)
|
||||
selectedLabels.append(label)
|
||||
} else {
|
||||
unselectedLabels.insert(label)
|
||||
}
|
||||
|
|
@ -50,7 +49,7 @@ import Views
|
|||
}
|
||||
for label in self.labels {
|
||||
if selLabels.contains(label) {
|
||||
self.selectedLabels.insert(label)
|
||||
self.selectedLabels.append(label)
|
||||
} else {
|
||||
self.unselectedLabels.insert(label)
|
||||
}
|
||||
|
|
@ -100,7 +99,7 @@ import Views
|
|||
|
||||
if let label = dataService.viewContext.object(with: labelObjectID) as? LinkedItemLabel {
|
||||
labels.insert(label, at: 0)
|
||||
selectedLabels.insert(label)
|
||||
selectedLabels.append(label)
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
|
|
|||
200
apple/OmnivoreKit/Sources/App/Views/SearchBar.swift
Normal file
200
apple/OmnivoreKit/Sources/App/Views/SearchBar.swift
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import Models
|
||||
import Services
|
||||
import SwiftUI
|
||||
import Views
|
||||
|
||||
@MainActor
|
||||
protocol Entry {
|
||||
func item(parent: LabelsEntryView) -> AnyView
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct LabelEntry: Entry {
|
||||
let label: LinkedItemLabel
|
||||
|
||||
func item(parent _: LabelsEntryView) -> AnyView {
|
||||
if let name = label.name, let hex = label.color, let color = Color(hex: hex) {
|
||||
return AnyView(LibraryItemLabelView(text: name, color: color))
|
||||
}
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public struct LabelsEntryView: View {
|
||||
@Binding var searchTerm: String
|
||||
@State var viewModel: LabelsViewModel
|
||||
@State var lastSelected = false
|
||||
@State var justInserted = false
|
||||
|
||||
let entries: [Entry]
|
||||
|
||||
@State private var totalHeight = CGFloat.zero
|
||||
@FocusState private var textFieldFocused: Bool
|
||||
@FocusState private var neverFocused: Bool
|
||||
|
||||
public init(
|
||||
searchTerm: Binding<String>,
|
||||
viewModel: LabelsViewModel
|
||||
) {
|
||||
self._searchTerm = searchTerm
|
||||
self.viewModel = viewModel
|
||||
|
||||
self.entries = Array(viewModel.selectedLabels.map { LabelEntry(label: $0) })
|
||||
}
|
||||
|
||||
func onTextSubmit() {
|
||||
if searchTerm.count < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// first see if there is a matching label
|
||||
let term = searchTerm.lowercased()
|
||||
if let label = viewModel.labels.first(where: { $0.name?.lowercased() == term }) {
|
||||
justInserted = true
|
||||
searchTerm = ""
|
||||
if !viewModel.selectedLabels.contains(label) {
|
||||
viewModel.selectedLabels.append(label)
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
lastSelected = false
|
||||
textFieldFocused = true
|
||||
justInserted = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var deletableTextField: some View {
|
||||
let str = NSAttributedString(
|
||||
string: searchTerm,
|
||||
attributes: [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 14)]
|
||||
)
|
||||
// Round it up to avoid jitter when typing
|
||||
let textWidth = max(25.0, Double(Int(str.size().width + 1)))
|
||||
let result = TextField("", text: $searchTerm)
|
||||
.frame(alignment: .topLeading)
|
||||
.frame(height: 25)
|
||||
.frame(width: textWidth)
|
||||
.padding(5)
|
||||
.font(Font.system(size: 14))
|
||||
.multilineTextAlignment(.leading)
|
||||
.onChange(of: searchTerm, perform: { newValue in
|
||||
print("NEW VALUE: ", newValue.count)
|
||||
if searchTerm.count >= 64 {
|
||||
searchTerm = String(searchTerm.prefix(64))
|
||||
}
|
||||
if searchTerm.isEmpty {
|
||||
// When we insert a new item we set the text to "" so this block is triggered
|
||||
// we need to ignore that special case.
|
||||
if justInserted {
|
||||
justInserted = false
|
||||
return
|
||||
}
|
||||
if lastSelected {
|
||||
if viewModel.selectedLabels.count > 0 {
|
||||
lastSelected = false
|
||||
viewModel.selectedLabels.removeLast()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastSelected = true
|
||||
searchTerm = "\u{200B}"
|
||||
}
|
||||
} else if searchTerm != "\u{200B}" {
|
||||
lastSelected = false
|
||||
}
|
||||
})
|
||||
.onSubmit {
|
||||
onTextSubmit()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func onTextDelete() -> Bool { if searchTerm.isEmpty {
|
||||
if lastSelected {
|
||||
if viewModel.selectedLabels.count > 0 {
|
||||
viewModel.selectedLabels.removeLast()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
||||
textFieldFocused = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lastSelected = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
// HStack(spacing: 0) {
|
||||
VStack {
|
||||
GeometryReader { geometry in
|
||||
self.generateLabelsContent(in: geometry)
|
||||
}
|
||||
}.padding(0)
|
||||
.frame(height: totalHeight)
|
||||
.background(Color.extensionPanelBackground)
|
||||
.cornerRadius(8)
|
||||
.onAppear {
|
||||
textFieldFocused = true
|
||||
}
|
||||
.onTapGesture {
|
||||
textFieldFocused = true
|
||||
}
|
||||
.transaction { $0.animation = nil }
|
||||
}
|
||||
|
||||
private func generateLabelsContent(in geom: GeometryProxy) -> some View {
|
||||
var width = CGFloat.zero
|
||||
var height = CGFloat.zero
|
||||
|
||||
return ZStack(alignment: .topLeading) {
|
||||
ForEach(Array(self.entries.enumerated()), id: \.offset) { _, entry in
|
||||
entry.item(parent: self)
|
||||
.padding(5)
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
width -= dim.width
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
deletableTextField
|
||||
.alignmentGuide(.leading, computeValue: { dim in
|
||||
if abs(width - dim.width) > geom.size.width {
|
||||
width = 0
|
||||
height -= dim.height
|
||||
}
|
||||
let result = width
|
||||
width = 0
|
||||
return result
|
||||
})
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
let result = height
|
||||
height = 0
|
||||
return result
|
||||
}).focused($textFieldFocused)
|
||||
}.background(viewHeightReader($totalHeight))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,8 @@ public extension Color {
|
|||
static var extensionPanelBackground: Color { Color("_extensionPanelBackground", bundle: .module) }
|
||||
static var extensionTextSubtle: Color { Color("_extensionTextSubtle", bundle: .module) }
|
||||
|
||||
static var textFieldBackground: Color { Color("_textFieldBackground", bundle: .module) }
|
||||
|
||||
// Apple system UIColor equivalents
|
||||
#if os(iOS)
|
||||
static var systemBackground: Color { Color(.systemBackground) }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0xFF",
|
||||
"green" : "0xFF",
|
||||
"red" : "0xFE"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2E",
|
||||
"green" : "0x2C",
|
||||
"red" : "0x2C"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import SwiftUI
|
||||
|
||||
public struct SearchBar: View {
|
||||
@Binding var searchTerm: String
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
searchTerm: Binding<String>
|
||||
) {
|
||||
self._searchTerm = searchTerm
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
TextField("Add Labels", text: $searchTerm)
|
||||
.frame(height: 36)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.leading, 28)
|
||||
.padding(.trailing, 28)
|
||||
.focused($isFocused)
|
||||
.overlay(
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
.foregroundColor(.appGrayText)
|
||||
.padding(.leading, 8)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
)
|
||||
|
||||
if isFocused {
|
||||
Button(
|
||||
action: {
|
||||
self.isFocused = false
|
||||
},
|
||||
label: {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
)
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue