Merge pull request #366 from omnivore-app/feature/label-chips-ios

Label Creation Form
This commit is contained in:
Satindar Dhillon 2022-04-06 08:19:33 -07:00 committed by GitHub
commit 0f18cac6a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 732 additions and 22 deletions

View file

@ -0,0 +1,87 @@
import Combine
import Models
import Services
import SwiftUI
import Views
final class ApplyLabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@Published var isLoading = true
@Published var selectedLabels = Set<FeedItemLabel>()
@Published var labels = [FeedItemLabel]()
var subscriptions = Set<AnyCancellable>()
func load(item: FeedItem, dataService: DataService) {
guard !hasLoadedInitialLabels else { return }
dataService.labelsPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels = result
self?.hasLoadedInitialLabels = true
self?.selectedLabels = Set(item.labels)
}
)
.store(in: &subscriptions)
}
func saveChanges(itemID: String, dataService: DataService, onComplete: @escaping ([FeedItemLabel]) -> Void) {
dataService.updateArticleLabelsPublisher(itemID: itemID, labelIDs: selectedLabels.map(\.id)).sink(
receiveCompletion: { _ in },
receiveValue: { onComplete($0) }
)
.store(in: &subscriptions)
}
}
struct ApplyLabelsView: View {
let item: FeedItem
let commitLabelChanges: ([FeedItemLabel]) -> Void
@EnvironmentObject var dataService: DataService
@Environment(\.presentationMode) private var presentationMode
@StateObject var viewModel = ApplyLabelsViewModel()
var body: some View {
NavigationView {
if viewModel.isLoading {
EmptyView()
} else {
List(viewModel.labels, id: \.self, selection: $viewModel.selectedLabels) { label in
if let textChip = TextChip(feedItemLabel: label) {
textChip
} else {
Text(label.name)
}
}
.environment(\.editMode, .constant(EditMode.active))
.navigationTitle("Apply Labels")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(
action: { presentationMode.wrappedValue.dismiss() },
label: { Text("Cancel") }
)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(
action: {
viewModel.saveChanges(itemID: item.id, dataService: dataService) { labels in
commitLabelChanges(labels)
presentationMode.wrappedValue.dismiss()
}
},
label: { Text("Save") }
)
}
}
}
}
.onAppear {
viewModel.load(item: item, dataService: dataService)
}
}
}

View file

@ -68,7 +68,7 @@ struct GridCardNavigationLink: View {
viewModel.itemAppeared(item: item, searchQuery: searchQuery, dataService: dataService)
}
}
.aspectRatio(2.1, contentMode: .fill)
.aspectRatio(1.8, contentMode: .fill)
.scaleEffect(scale)
}
}

View file

@ -167,6 +167,11 @@ import Views
}
}
}
.sheet(item: $viewModel.itemUnderLabelEdit) { item in
ApplyLabelsView(item: item) { labels in
viewModel.updateLabels(itemID: item.id, labels: labels)
}
}
}
}
}
@ -323,6 +328,8 @@ import Views
case .delete:
itemToRemove = item
confirmationShown = true
case .editLabels:
viewModel.itemUnderLabelEdit = item
}
}

View file

@ -14,6 +14,7 @@ final class HomeFeedViewModel: ObservableObject {
@Published var items = [FeedItem]()
@Published var isLoading = false
@Published var showPushNotificationPrimer = false
@Published var itemUnderLabelEdit: FeedItem?
var cursor: String?
var sendProgressUpdates = false
@ -182,4 +183,11 @@ final class HomeFeedViewModel: ObservableObject {
items[index].readingProgress = progress
}
}
func updateLabels(itemID: String, labels: [FeedItemLabel]) {
guard let item = items.first(where: { $0.id == itemID }) else { return }
if let index = items.firstIndex(of: item) {
items[index].labels = labels
}
}
}

View file

@ -0,0 +1,195 @@
import Combine
import Models
import Services
import SwiftUI
import Views
final class LabelsViewModel: ObservableObject {
private var hasLoadedInitialLabels = false
@Published var isLoading = false
@Published var labels = [FeedItemLabel]()
@Published var showCreateEmailModal = false
var subscriptions = Set<AnyCancellable>()
func loadLabels(dataService: DataService) {
guard !hasLoadedInitialLabels else { return }
isLoading = true
dataService.labelsPublisher().sink(
receiveCompletion: { _ in },
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels = result
self?.hasLoadedInitialLabels = true
}
)
.store(in: &subscriptions)
}
func createLabel(dataService: DataService, name: String, color: Color, description: String?) {
isLoading = true
dataService.createLabelPublisher(
name: name,
color: color.hex ?? "",
description: description
).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
receiveValue: { [weak self] result in
self?.isLoading = false
self?.labels.insert(result, at: 0)
self?.showCreateEmailModal = false
}
)
.store(in: &subscriptions)
}
func deleteLabel(dataService: DataService, labelID: String) {
isLoading = true
dataService.removeLabelPublisher(labelID: labelID).sink(
receiveCompletion: { [weak self] _ in
self?.isLoading = false
},
receiveValue: { [weak self] _ in
self?.isLoading = false
self?.labels.removeAll { $0.id == labelID }
}
)
.store(in: &subscriptions)
}
}
struct LabelsView: View {
@EnvironmentObject var dataService: DataService
@StateObject var viewModel = LabelsViewModel()
@State private var showDeleteConfirmation = false
@State private var labelToRemoveID: String?
let footerText = "Use labels to create curated collections of links."
var body: some View {
Group {
#if os(iOS)
if #available(iOS 15.0, *) {
Form {
innerBody
.alert("Are you sure you want to delete this label?", isPresented: $showDeleteConfirmation) {
Button("Remove Link", role: .destructive) {
if let labelID = labelToRemoveID {
withAnimation {
viewModel.deleteLabel(dataService: dataService, labelID: labelID)
}
}
self.labelToRemoveID = nil
}
Button("Cancel", role: .cancel) { self.labelToRemoveID = nil }
}
}
} else {
Form { innerBody }
}
#elseif os(macOS)
List {
innerBody
}
.listStyle(InsetListStyle())
#endif
}
.onAppear { viewModel.loadLabels(dataService: dataService) }
}
private var innerBody: some View {
Group {
Section(footer: Text(footerText)) {
Button(
action: { viewModel.showCreateEmailModal = true },
label: {
HStack {
Image(systemName: "plus.circle.fill").foregroundColor(.green)
Text("Create a new Label")
Spacer()
}
}
)
.disabled(viewModel.isLoading)
}
if !viewModel.labels.isEmpty {
Section(header: Text("Labels")) {
ForEach(viewModel.labels, id: \.id) { label in
HStack {
Text(label.name)
Spacer()
Button(
action: {
labelToRemoveID = label.id
showDeleteConfirmation = true
},
label: { Image(systemName: "trash") }
)
}
}
}
}
}
.navigationTitle("Labels")
.sheet(isPresented: $viewModel.showCreateEmailModal) {
CreateLabelView(viewModel: viewModel)
}
}
}
struct CreateLabelView: View {
@EnvironmentObject var dataService: DataService
@ObservedObject var viewModel: LabelsViewModel
@State private var newLabelName = ""
@State private var newLabelColor = Color.clear
var body: some View {
NavigationView {
VStack(spacing: 16) {
TextField("Label Name", text: $newLabelName)
.keyboardType(.alphabet)
.textFieldStyle(StandardTextFieldStyle())
ColorPicker(
newLabelColor == .clear ? "Select Color" : newLabelColor.description,
selection: $newLabelColor
)
Button(
action: {
viewModel.createLabel(
dataService: dataService,
name: newLabelName,
color: newLabelColor,
description: nil
)
},
label: { Text("Create") }
)
.buttonStyle(SolidCapsuleButtonStyle(color: .appDeepBackground, width: 300))
.disabled(viewModel.isLoading || newLabelName.isEmpty || newLabelColor == .clear)
Spacer()
}
.padding()
.toolbar {
ToolbarItem(placement: .automatic) {
Button(
action: { viewModel.showCreateEmailModal = false },
label: {
Image(systemName: "xmark")
.foregroundColor(.appGrayTextContrast)
}
)
}
}
.navigationTitle("Create New Label")
.navigationBarTitleDisplayMode(.inline)
}
}
}

View file

@ -63,6 +63,12 @@ struct ProfileView: View {
}
Section {
if FeatureFlag.enableLabels {
NavigationLink(destination: LabelsView()) {
Text("Labels")
}
}
NavigationLink(destination: NewsletterEmailsView()) {
Text("Emails")
}

View file

@ -10,11 +10,8 @@ struct SafariWebLink: Identifiable {
}
func encodeHighlightResult(_ highlight: Highlight) -> [String: Any]? {
let data = try? JSONEncoder().encode(highlight)
if let data = data, let dictionary = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] {
return dictionary
}
return nil
guard let data = try? JSONEncoder().encode(highlight) else { return nil }
return try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any]
}
final class WebReaderViewModel: ObservableObject {

View file

@ -28,6 +28,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
public let slug: String
public let isArchived: Bool
public let contentReader: String?
public var labels: [FeedItemLabel]
public init(
id: String,
@ -46,7 +47,8 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
publishDate: Date?,
slug: String,
isArchived: Bool,
contentReader: String?
contentReader: String?,
labels: [FeedItemLabel]
) {
self.id = id
self.title = title
@ -65,10 +67,12 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
self.slug = slug
self.isArchived = isArchived
self.contentReader = contentReader
self.labels = labels
}
enum CodingKeys: String, CodingKey {
case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url
// swiftlint:disable:next line_length
case id, title, createdAt, savedAt, image, isArchived, readingProgressPercent, readingProgressAnchorIndex, slug, contentReader, url, labels
}
public init(from decoder: Decoder) throws {
@ -85,6 +89,7 @@ public struct FeedItem: Identifiable, Hashable, Decodable {
contentReader = try container.decode(String.self, forKey: .contentReader)
pageURLString = try container.decode(String.self, forKey: .url)
isArchived = try container.decode(Bool.self, forKey: .isArchived)
labels = try container.decode([FeedItemLabel].self, forKey: .labels)
self.onDeviceImageURLString = nil
self.documentDirectoryPath = nil

View file

@ -0,0 +1,23 @@
import Foundation
public struct FeedItemLabel: Decodable, Hashable {
public let id: String
public let name: String
public let color: String
public let createdAt: Date?
public let description: String?
public init(
id: String,
name: String,
color: String,
createdAt: Date?,
description: String?
) {
self.id = id
self.name = name
self.color = color
self.createdAt = createdAt
self.description = description
}
}

View file

@ -2970,8 +2970,11 @@ extension Objects {
let savedByViewer: [String: Bool]
let shareInfo: [String: Objects.LinkShareInfo]
let sharedComment: [String: String]
let siteIcon: [String: String]
let siteName: [String: String]
let slug: [String: String]
let title: [String: String]
let uploadFileId: [String: String]
let url: [String: String]
enum TypeName: String, Codable {
@ -3088,6 +3091,14 @@ extension Objects.Article: Decodable {
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "siteIcon":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "siteName":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "slug":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -3096,6 +3107,10 @@ extension Objects.Article: Decodable {
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "uploadFileId":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
}
case "url":
if let value = try container.decode(String?.self, forKey: codingKey) {
map.set(key: field, hash: alias, value: value as Any)
@ -3134,8 +3149,11 @@ extension Objects.Article: Decodable {
savedByViewer = map["savedByViewer"]
shareInfo = map["shareInfo"]
sharedComment = map["sharedComment"]
siteIcon = map["siteIcon"]
siteName = map["siteName"]
slug = map["slug"]
title = map["title"]
uploadFileId = map["uploadFileId"]
url = map["url"]
}
}
@ -3587,6 +3605,51 @@ extension Fields where TypeLock == Objects.Article {
return selection.mock()
}
}
func uploadFileId() throws -> String? {
let field = GraphQLField.leaf(
name: "uploadFileId",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.uploadFileId[field.alias!]
case .mocking:
return nil
}
}
func siteName() throws -> String? {
let field = GraphQLField.leaf(
name: "siteName",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.siteName[field.alias!]
case .mocking:
return nil
}
}
func siteIcon() throws -> String? {
let field = GraphQLField.leaf(
name: "siteIcon",
arguments: []
)
select(field)
switch response {
case let .decoding(data):
return data.siteIcon[field.alias!]
case .mocking:
return nil
}
}
}
extension Selection where TypeLock == Never, Type == Never {
@ -10784,7 +10847,7 @@ extension Fields where TypeLock == Objects.Label {
}
}
func createdAt() throws -> DateTime {
func createdAt() throws -> DateTime? {
let field = GraphQLField.leaf(
name: "createdAt",
arguments: []
@ -10793,12 +10856,9 @@ extension Fields where TypeLock == Objects.Label {
switch response {
case let .decoding(data):
if let data = data.createdAt[field.alias!] {
return data
}
throw HttpError.badpayload
return data.createdAt[field.alias!]
case .mocking:
return DateTime.mockValue
return nil
}
}
}
@ -16779,6 +16839,10 @@ extension Enums {
/// SortBy
enum SortBy: String, CaseIterable, Codable {
case updatedTime = "UPDATED_TIME"
case score = "SCORE"
case savedAt = "SAVED_AT"
}
}
@ -16956,6 +17020,8 @@ extension Enums {
case payloadTooLarge = "PAYLOAD_TOO_LARGE"
case uploadFileMissing = "UPLOAD_FILE_MISSING"
case elasticError = "ELASTIC_ERROR"
}
}

View file

@ -0,0 +1,62 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func createLabelPublisher(
name: String,
color: String,
description: String?
) -> AnyPublisher<FeedItemLabel, BasicError> {
enum MutationResult {
case saved(label: FeedItemLabel)
case error(errorCode: Enums.CreateLabelErrorCode)
}
let selection = Selection<MutationResult, Unions.CreateLabelResult> {
try $0.on(
createLabelSuccess: .init { .saved(label: try $0.label(selection: feedItemLabelSelection)) },
createLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.createLabel(
input: InputObjects.CreateLabelInput(
name: name,
color: color,
description: OptionalArgument(description)
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: "graphql error: \(graphqlError)")))
}
switch payload.data {
case let .saved(label: label):
promise(.success(label))
case let .error(errorCode: errorCode):
promise(.failure(.message(messageText: errorCode.rawValue)))
}
case .failure:
promise(.failure(.message(messageText: "graphql error")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,53 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func removeLabelPublisher(labelID: String) -> AnyPublisher<Bool, BasicError> {
enum MutationResult {
case success(labelID: String)
case error(errorCode: Enums.DeleteLabelErrorCode)
}
let selection = Selection<MutationResult, Unions.DeleteLabelResult> {
try $0.on(
deleteLabelSuccess: .init {
.success(labelID: try $0.label(selection: Selection.Label { try $0.id() }))
},
deleteLabelError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.deleteLabel(id: labelID, selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if payload.errors != nil {
promise(.failure(.message(messageText: "Error removing label")))
}
switch payload.data {
case .success:
promise(.success(true))
case .error:
promise(.failure(.message(messageText: "Error removing label")))
}
case .failure:
promise(.failure(.message(messageText: "Error removing label")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -0,0 +1,57 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func updateArticleLabelsPublisher(itemID: String, labelIDs: [String]) -> AnyPublisher<[FeedItemLabel], BasicError> {
enum MutationResult {
case saved(feedItem: [FeedItemLabel])
case error(errorCode: Enums.SetLabelsErrorCode)
}
let selection = Selection<MutationResult, Unions.SetLabelsResult> {
try $0.on(
setLabelsSuccess: .init { .saved(feedItem: try $0.labels(selection: feedItemLabelSelection.list)) },
setLabelsError: .init { .error(errorCode: try $0.errorCodes().first ?? .badRequest) }
)
}
let mutation = Selection.Mutation {
try $0.setLabels(
input: InputObjects.SetLabelsInput(
linkId: itemID,
labelIds: labelIDs
),
selection: selection
)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(mutation, to: path, headers: headers) { result in
switch result {
case let .success(payload):
if let graphqlError = payload.errors {
promise(.failure(.message(messageText: graphqlError.first.debugDescription)))
}
switch payload.data {
case let .saved(labels):
promise(.success(labels))
case .error:
promise(.failure(.message(messageText: "failed to set labels")))
}
case .failure:
promise(.failure(.message(messageText: "failed to set labels")))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -3,7 +3,6 @@ import Foundation
import Models
import SwiftGraphQL
// swiftlint:disable:next function_body_length
public extension DataService {
func articleContentPublisher(username: String, slug: String) -> AnyPublisher<ArticleContent, ServerError> {
enum QueryResult {

View file

@ -0,0 +1,49 @@
import Combine
import Foundation
import Models
import SwiftGraphQL
public extension DataService {
func labelsPublisher() -> AnyPublisher<[FeedItemLabel], ServerError> {
enum QueryResult {
case success(result: [FeedItemLabel])
case error(error: String)
}
let selection = Selection<QueryResult, Unions.LabelsResult> {
try $0.on(labelsSuccess: .init {
QueryResult.success(result: try $0.labels(selection: feedItemLabelSelection.list))
},
labelsError: .init {
QueryResult.error(error: try $0.errorCodes().description)
})
}
let query = Selection.Query {
try $0.labels(selection: selection)
}
let path = appEnvironment.graphqlPath
let headers = networker.defaultHeaders
return Deferred {
Future { promise in
send(query, to: path, headers: headers) { result in
switch result {
case let .success(payload):
switch payload.data {
case let .success(result: result):
promise(.success(result))
case .error:
promise(.failure(.unknown))
}
case .failure:
promise(.failure(.unknown))
}
}
}
}
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}

View file

@ -143,7 +143,8 @@ let homeFeedItemSelection = Selection.Article {
publishDate: try $0.publishedAt()?.value,
slug: try $0.slug(),
isArchived: try $0.isArchived(),
contentReader: try $0.contentReader().rawValue
contentReader: try $0.contentReader().rawValue,
labels: try $0.labels(selection: feedItemLabelSelection.list.nullable) ?? []
)
}

View file

@ -0,0 +1,12 @@
import Models
import SwiftGraphQL
let feedItemLabelSelection = Selection.Label {
FeedItemLabel(
id: try $0.id(),
name: try $0.name(),
color: try $0.color(),
createdAt: try $0.createdAt()?.value,
description: try $0.description()
)
}

View file

@ -0,0 +1,63 @@
import SwiftUI
public extension Color {
/// Inititializes a `Color` from a hex value
/// - Parameter hex: Color hex value. ex: `#FFFFFF`
///
init?(hex: String) {
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
var rgb: UInt64 = 0
var red: CGFloat = 0.0
var green: CGFloat = 0.0
var blue: CGFloat = 0.0
var alpha: CGFloat = 1.0
let length = hexSanitized.count
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
if length == 6 {
red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
blue = CGFloat(rgb & 0x0000FF) / 255.0
} else if length == 8 {
red = CGFloat((rgb & 0xFF00_0000) >> 24) / 255.0
green = CGFloat((rgb & 0x00FF_0000) >> 16) / 255.0
blue = CGFloat((rgb & 0x0000_FF00) >> 8) / 255.0
alpha = CGFloat(rgb & 0x0000_00FF) / 255.0
} else {
return nil
}
self.init(red: red, green: green, blue: blue, opacity: alpha)
}
var hex: String? {
if let hexValue = toHex() {
return "#\(hexValue)"
} else {
return nil
}
}
private func toHex() -> String? {
let uic = UIColor(self)
guard let components = uic.cgColor.components, components.count >= 3 else {
return nil
}
let red = Float(components[0])
let green = Float(components[1])
let blue = Float(components[2])
return String(
format: "%02lX%02lX%02lX",
lroundf(red * 255),
lroundf(green * 255),
lroundf(blue * 255)
)
}
}

View file

@ -14,6 +14,6 @@ public enum FeatureFlag {
public static let enablePushNotifications = false
public static let enableShareButton = false
public static let enableSnooze = false
public static let showFeedItemTags = false
public static let enableLabels = true
public static let useLocalWebView = true
}

View file

@ -5,6 +5,7 @@ import Utils
public enum GridCardAction {
case toggleArchiveStatus
case delete
case editLabels
}
public struct GridCard: View {
@ -42,6 +43,10 @@ public struct GridCard: View {
var contextMenuView: some View {
Group {
Button(
action: { menuActionHandler(.editLabels) },
label: { Label("Edit Labels", systemImage: "tag") }
)
Button(
action: { menuActionHandler(.toggleArchiveStatus) },
label: {
@ -156,11 +161,12 @@ public struct GridCard: View {
.onTapGesture { tapHandler() }
// Category Labels
if FeatureFlag.showFeedItemTags {
if FeatureFlag.enableLabels {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
TextChip(text: "label", color: .red)
TextChip(text: "longer label", color: .blue)
ForEach(item.labels, id: \.self) {
TextChip(feedItemLabel: $0)
}
Spacer()
}
.frame(height: 30)

View file

@ -1,11 +1,25 @@
import Models
import SwiftUI
import Utils
public struct TextChip: View {
public init(text: String, color: Color) {
self.text = text
self.color = color
}
public init?(feedItemLabel: FeedItemLabel) {
guard let color = Color(hex: feedItemLabel.color) else { return nil }
self.text = feedItemLabel.name
self.color = color
}
struct TextChip: View {
let text: String
let color: Color
let cornerRadius = 20.0
var body: some View {
public var body: some View {
Text(text)
.padding(.horizontal, 10)
.padding(.vertical, 5)