- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem with normalization logic and backend field fetching - Change default dailyUsageHours from 1h to 24h - Replace goal editor stepper with day/hour/minute wheel pickers - Update app icon colors and remove duplicate icon assets - Move SavedBattery.swift into Batteries/ directory, remove Pods group - Add iPad-only flag and start frame support to screenshot framing scripts - Rework localized App Store screenshot titles across all languages - Add runtime goals and BOM completed items to sample data - Bump version to 1.5.1 (build 41) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
688 lines
23 KiB
Swift
688 lines
23 KiB
Swift
import SwiftUI
|
|
|
|
struct ComponentLibraryItem: Identifiable, Equatable {
|
|
struct AffiliateLink: Identifiable, Equatable {
|
|
let id: String
|
|
let url: URL
|
|
let country: String?
|
|
}
|
|
|
|
let id: String
|
|
let name: String
|
|
let translations: [String: String]
|
|
let voltageIn: Double?
|
|
let voltageOut: Double?
|
|
let watt: Double?
|
|
let dutyCyclePercent: Double?
|
|
let defaultUtilizationFactorPercent: Double?
|
|
let iconURL: URL?
|
|
let affiliateLinks: [AffiliateLink]
|
|
|
|
var displayVoltage: Double? {
|
|
voltageIn ?? voltageOut
|
|
}
|
|
|
|
var current: Double? {
|
|
guard let power = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
|
|
return power / voltage
|
|
}
|
|
|
|
var voltageLabel: String? {
|
|
guard let voltage = displayVoltage else { return nil }
|
|
return String(format: "%.1fV", voltage)
|
|
}
|
|
|
|
var powerLabel: String? {
|
|
guard let power = watt else { return nil }
|
|
return String(format: "%.0fW", power)
|
|
}
|
|
|
|
var currentLabel: String? {
|
|
guard let current else { return nil }
|
|
return String(format: "%.1fA", current)
|
|
}
|
|
|
|
var normalizedDutyCyclePercent: Double? {
|
|
Self.normalizePercentValue(dutyCyclePercent)
|
|
}
|
|
|
|
var normalizedUtilizationFactorPercent: Double? {
|
|
Self.normalizePercentValue(defaultUtilizationFactorPercent)
|
|
}
|
|
|
|
var defaultDailyUsageHours: Double? {
|
|
guard let percent = normalizedUtilizationFactorPercent else { return nil }
|
|
return (percent / 100) * 24
|
|
}
|
|
|
|
var localizedName: String {
|
|
localizedName(usingPreferredLanguages: Locale.preferredLanguages) ?? name
|
|
}
|
|
|
|
func localizedName(usingPreferredLanguages languages: [String]) -> String? {
|
|
guard let primaryIdentifier = languages.first else { return nil }
|
|
let locale = Locale(identifier: primaryIdentifier)
|
|
return translation(for: locale)
|
|
}
|
|
|
|
var primaryAffiliateLink: AffiliateLink? {
|
|
affiliateLink(matching: Locale.current.region?.identifier)
|
|
}
|
|
|
|
func localizedName(for locale: Locale) -> String {
|
|
translation(for: locale) ?? name
|
|
}
|
|
|
|
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
|
|
guard !affiliateLinks.isEmpty else { return nil }
|
|
|
|
let normalizedRegionCode = regionCode?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
|
|
if let normalizedRegionCode, !normalizedRegionCode.isEmpty {
|
|
if let exactMatch = affiliateLinks.first(where: { link in
|
|
link.country?.lowercased() == normalizedRegionCode
|
|
}) {
|
|
return exactMatch
|
|
}
|
|
}
|
|
|
|
if let fallbackWithoutCountry = affiliateLinks.first(where: { $0.country == nil }) {
|
|
return fallbackWithoutCountry
|
|
}
|
|
|
|
return affiliateLinks.first
|
|
}
|
|
|
|
private func translation(for locale: Locale) -> String? {
|
|
guard !translations.isEmpty else { return nil }
|
|
|
|
let lookupKeys = ComponentLibraryItem.lookupKeys(for: locale)
|
|
|
|
for key in lookupKeys {
|
|
if let match = translations[key] {
|
|
return match
|
|
}
|
|
}
|
|
|
|
let normalizedTranslations = translations.reduce(into: [String: String]()) { result, element in
|
|
let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(element.key)
|
|
result[normalizedKey] = element.value
|
|
|
|
if let languageOnlyKey = ComponentLibraryItem.languageComponent(fromNormalizedKey: normalizedKey),
|
|
result[languageOnlyKey] == nil {
|
|
result[languageOnlyKey] = element.value
|
|
}
|
|
}
|
|
|
|
for key in lookupKeys {
|
|
let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(key)
|
|
if let match = normalizedTranslations[normalizedKey] {
|
|
return match
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func lookupKeys(for locale: Locale) -> [String] {
|
|
var keys: [String] = []
|
|
|
|
func append(_ value: String?) {
|
|
guard let value, !value.isEmpty else { return }
|
|
|
|
for variant in variants(for: value) {
|
|
if !keys.contains(variant) {
|
|
keys.append(variant)
|
|
}
|
|
}
|
|
}
|
|
|
|
append(locale.identifier)
|
|
|
|
if let languageCode = locale.language.languageCode?.identifier.lowercased() {
|
|
append(languageCode)
|
|
}
|
|
|
|
if let regionIdentifier = locale.region?.identifier.uppercased(),
|
|
let languageIdentifier = locale.language.languageCode?.identifier.lowercased() {
|
|
append("\(languageIdentifier)_\(regionIdentifier)")
|
|
}
|
|
|
|
return keys
|
|
}
|
|
|
|
private static func normalizeLocaleKey(_ key: String) -> String {
|
|
let sanitized = key.replacingOccurrences(of: "-", with: "_")
|
|
let parts = sanitized.split(separator: "_", omittingEmptySubsequences: true)
|
|
|
|
guard let languagePart = parts.first else {
|
|
return sanitized.lowercased()
|
|
}
|
|
|
|
let language = languagePart.lowercased()
|
|
|
|
if parts.count >= 2, let regionPart = parts.last {
|
|
return "\(language)_\(regionPart.uppercased())"
|
|
}
|
|
|
|
return language
|
|
}
|
|
|
|
private static func languageComponent(fromNormalizedKey key: String) -> String? {
|
|
let components = key.split(separator: "_", omittingEmptySubsequences: true)
|
|
guard let language = components.first else { return nil }
|
|
return String(language)
|
|
}
|
|
|
|
private static func variants(for key: String) -> [String] {
|
|
var collected: [String] = []
|
|
let underscore = key.replacingOccurrences(of: "-", with: "_")
|
|
let hyphen = key.replacingOccurrences(of: "_", with: "-")
|
|
|
|
for candidate in Set([key, underscore, hyphen]) {
|
|
collected.append(candidate)
|
|
}
|
|
|
|
return collected
|
|
}
|
|
|
|
private static func normalizePercentValue(_ value: Double?) -> Double? {
|
|
guard var percent = value else { return nil }
|
|
if percent <= 0 {
|
|
// Backend sends 0 to represent 100% utilization.
|
|
percent = 100
|
|
}
|
|
return min(max(percent, 0), 100)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ComponentLibraryViewModel: ObservableObject {
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var items: [ComponentLibraryItem] = []
|
|
@Published private(set) var errorMessage: String?
|
|
|
|
private let baseURL = URL(string: "https://base.voltplan.app")!
|
|
private let urlSession: URLSession
|
|
|
|
init(urlSession: URLSession = .shared) {
|
|
self.urlSession = urlSession
|
|
}
|
|
|
|
func load() async {
|
|
guard !isLoading else { return }
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let fetchedItems = try await fetchComponents()
|
|
items = fetchedItems
|
|
} catch {
|
|
items = []
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
func refresh() async {
|
|
isLoading = false
|
|
await load()
|
|
}
|
|
|
|
private func fetchComponents() async throws -> [ComponentLibraryItem] {
|
|
let perPage = 200
|
|
var page = 1
|
|
var allRecords: [PocketBaseRecord] = []
|
|
|
|
while true {
|
|
var components = URLComponents(
|
|
url: baseURL.appendingPathComponent("api/collections/components/records"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
components?.queryItems = [
|
|
URLQueryItem(name: "filter", value: "(type='load')"),
|
|
URLQueryItem(name: "sort", value: "+name"),
|
|
URLQueryItem(
|
|
name: "fields",
|
|
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor"
|
|
),
|
|
URLQueryItem(name: "page", value: "\(page)"),
|
|
URLQueryItem(name: "perPage", value: "\(perPage)")
|
|
]
|
|
|
|
guard let url = components?.url else {
|
|
throw URLError(.badURL)
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
|
|
let (data, response) = try await urlSession.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
|
|
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
|
|
allRecords.append(contentsOf: decoded.items)
|
|
|
|
let isLastPage: Bool
|
|
if let totalPages = decoded.totalPages, totalPages > 0 {
|
|
isLastPage = page >= totalPages
|
|
} else {
|
|
isLastPage = decoded.items.count < perPage
|
|
}
|
|
|
|
if isLastPage {
|
|
break
|
|
}
|
|
|
|
page += 1
|
|
}
|
|
|
|
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
|
|
let mappedItems = allRecords.map { record in
|
|
ComponentLibraryItem(
|
|
id: record.id,
|
|
name: record.name,
|
|
translations: record.translations?.flattened ?? [:],
|
|
voltageIn: record.voltageIn,
|
|
voltageOut: record.voltageOut,
|
|
watt: record.watt,
|
|
dutyCyclePercent: record.dutyCycle,
|
|
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
|
iconURL: iconURL(for: record),
|
|
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
|
|
)
|
|
}
|
|
for item in mappedItems {
|
|
if let url = item.iconURL {
|
|
Task.detached(priority: .background) {
|
|
await IconCache.shared.prefetch(url)
|
|
}
|
|
}
|
|
}
|
|
return mappedItems
|
|
}
|
|
|
|
private func fetchAffiliateLinks(for componentIDs: [String]) async throws -> [String: [ComponentLibraryItem.AffiliateLink]] {
|
|
let uniqueIDs = Array(Set(componentIDs))
|
|
guard !uniqueIDs.isEmpty else { return [:] }
|
|
|
|
let idSet = Set(uniqueIDs)
|
|
let perPage = 200
|
|
let chunkSize = 15
|
|
let chunks: [[String]] = stride(from: 0, to: uniqueIDs.count, by: chunkSize).map { index in
|
|
let upperBound = min(index + chunkSize, uniqueIDs.count)
|
|
return Array(uniqueIDs[index..<upperBound])
|
|
}
|
|
var aggregated: [String: [ComponentLibraryItem.AffiliateLink]] = [:]
|
|
|
|
for chunk in chunks {
|
|
guard !chunk.isEmpty else { continue }
|
|
|
|
let filterValue = chunk
|
|
.map { "component='\(escapeFilterValue($0))'" }
|
|
.joined(separator: " || ")
|
|
|
|
var page = 1
|
|
|
|
while true {
|
|
var components = URLComponents(
|
|
url: baseURL.appendingPathComponent("api/collections/affiliate_links/records"),
|
|
resolvingAgainstBaseURL: false
|
|
)
|
|
|
|
var queryItems = [
|
|
URLQueryItem(name: "page", value: "\(page)"),
|
|
URLQueryItem(name: "perPage", value: "\(perPage)"),
|
|
URLQueryItem(name: "fields", value: "id,url,component,country")
|
|
]
|
|
|
|
if !filterValue.isEmpty {
|
|
queryItems.append(URLQueryItem(name: "filter", value: "(\(filterValue))"))
|
|
}
|
|
|
|
components?.queryItems = queryItems
|
|
|
|
guard let url = components?.url else {
|
|
throw URLError(.badURL)
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.cachePolicy = .reloadIgnoringLocalCacheData
|
|
|
|
let (data, response) = try await urlSession.data(for: request)
|
|
|
|
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
|
|
let decoded = try JSONDecoder().decode(AffiliateLinksResponse.self, from: data)
|
|
|
|
for record in decoded.items {
|
|
guard let componentID = record.component, idSet.contains(componentID) else { continue }
|
|
guard let url = URL(string: record.url) else { continue }
|
|
|
|
let normalizedCountry = record.country?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let countryCode = normalizedCountry?.isEmpty == true ? nil : normalizedCountry?.uppercased()
|
|
|
|
let link = ComponentLibraryItem.AffiliateLink(
|
|
id: record.id,
|
|
url: url,
|
|
country: countryCode
|
|
)
|
|
|
|
var links = aggregated[componentID, default: []]
|
|
if !links.contains(where: { $0.id == record.id }) {
|
|
links.append(link)
|
|
aggregated[componentID] = links
|
|
}
|
|
}
|
|
|
|
let isLastPage: Bool
|
|
if decoded.totalPages > 0 {
|
|
isLastPage = page >= decoded.totalPages
|
|
} else {
|
|
isLastPage = decoded.items.count < perPage
|
|
}
|
|
|
|
if isLastPage { break }
|
|
page += 1
|
|
}
|
|
}
|
|
|
|
for key in Array(aggregated.keys) {
|
|
aggregated[key]?.sort { lhs, rhs in
|
|
let lhsCountry = lhs.country ?? ""
|
|
let rhsCountry = rhs.country ?? ""
|
|
|
|
if lhsCountry == rhsCountry {
|
|
return lhs.url.absoluteString < rhs.url.absoluteString
|
|
}
|
|
|
|
return lhsCountry < rhsCountry
|
|
}
|
|
}
|
|
|
|
return aggregated
|
|
}
|
|
|
|
private func iconURL(for record: PocketBaseRecord) -> URL? {
|
|
guard let icon = record.icon else { return nil }
|
|
|
|
return baseURL
|
|
.appendingPathComponent("api")
|
|
.appendingPathComponent("files")
|
|
.appendingPathComponent(record.collectionId)
|
|
.appendingPathComponent(record.id)
|
|
.appendingPathComponent(icon)
|
|
}
|
|
|
|
private func escapeFilterValue(_ value: String) -> String {
|
|
value.replacingOccurrences(of: "'", with: "\\'")
|
|
}
|
|
|
|
private struct PocketBaseResponse: Decodable {
|
|
let page: Int?
|
|
let perPage: Int?
|
|
let totalPages: Int?
|
|
let items: [PocketBaseRecord]
|
|
}
|
|
|
|
private struct PocketBaseRecord: Decodable {
|
|
let id: String
|
|
let collectionId: String
|
|
let name: String
|
|
let translations: TranslationsContainer?
|
|
let icon: String?
|
|
let voltageIn: Double?
|
|
let voltageOut: Double?
|
|
let watt: Double?
|
|
let dutyCycle: Double?
|
|
let defaultUtilizationFactor: Double?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case collectionId
|
|
case name
|
|
case translations
|
|
case icon
|
|
case voltageIn = "voltage_in"
|
|
case voltageOut = "voltage_out"
|
|
case watt
|
|
case dutyCycle = "duty_cycle"
|
|
case defaultUtilizationFactor = "default_utilization_factor"
|
|
}
|
|
|
|
struct TranslationsContainer: Decodable {
|
|
private let storage: [String: TranslationValue]
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
storage = try container.decode([String: TranslationValue].self)
|
|
}
|
|
|
|
var flattened: [String: String] {
|
|
storage.reduce(into: [:]) { result, entry in
|
|
if let value = entry.value.flattened {
|
|
result[entry.key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum TranslationValue: Decodable {
|
|
case string(String)
|
|
case dictionary([String: String])
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let singleValue = try decoder.singleValueContainer()
|
|
if let string = try? singleValue.decode(String.self) {
|
|
self = .string(string)
|
|
return
|
|
}
|
|
|
|
if let dictionary = try? singleValue.decode([String: String].self) {
|
|
self = .dictionary(dictionary)
|
|
return
|
|
}
|
|
|
|
self = .dictionary([:])
|
|
}
|
|
|
|
var flattened: String? {
|
|
switch self {
|
|
case .string(let value):
|
|
return value.isEmpty ? nil : value
|
|
case .dictionary(let dictionary):
|
|
if let name = dictionary["name"], !name.isEmpty {
|
|
return name
|
|
}
|
|
if let value = dictionary["value"], !value.isEmpty {
|
|
return value
|
|
}
|
|
return dictionary.values.first(where: { !$0.isEmpty })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AffiliateLinksResponse: Decodable {
|
|
let page: Int
|
|
let totalPages: Int
|
|
let items: [AffiliateLinkRecord]
|
|
}
|
|
|
|
private struct AffiliateLinkRecord: Decodable {
|
|
let id: String
|
|
let url: String
|
|
let component: String?
|
|
let country: String?
|
|
}
|
|
}
|
|
|
|
struct ComponentLibraryView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@StateObject private var viewModel = ComponentLibraryViewModel()
|
|
@State private var searchText: String = ""
|
|
let onSelect: (ComponentLibraryItem) -> Void
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
.navigationTitle("VoltPlan Library")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
.accessibilityIdentifier("library-view-close-button")
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await viewModel.load()
|
|
}
|
|
.refreshable {
|
|
await viewModel.refresh()
|
|
}
|
|
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search components")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if viewModel.isLoading && viewModel.items.isEmpty {
|
|
ProgressView("Loading components")
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
} else if let errorMessage = viewModel.errorMessage {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.orange)
|
|
Text("Unable to load components")
|
|
.font(.headline)
|
|
Text(errorMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
Button("Retry") {
|
|
Task { await viewModel.refresh() }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
} else if filteredItems.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: searchText.isEmpty ? "sparkles.rectangle.stack" : "magnifyingglass")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.secondary)
|
|
Text(searchText.isEmpty ? "No components available" : "No matches")
|
|
.font(.headline)
|
|
Text(searchText.isEmpty ? "Check back soon for new loads from VoltPlan." : "Try searching for a different name.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
} else {
|
|
List {
|
|
ForEach(filteredItems) { item in
|
|
Button {
|
|
onSelect(item)
|
|
dismiss()
|
|
} label: {
|
|
ComponentRow(item: item)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
poweredByVoltplanRow
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
}
|
|
}
|
|
|
|
private var filteredItems: [ComponentLibraryItem] {
|
|
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmedQuery.isEmpty else { return viewModel.items }
|
|
|
|
return viewModel.items.filter { item in
|
|
let localizedName = item.localizedName
|
|
return localizedName.localizedCaseInsensitiveContains(trimmedQuery)
|
|
|| item.name.localizedCaseInsensitiveContains(trimmedQuery)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var poweredByVoltplanRow: some View {
|
|
if let url = URL(string: "https://voltplan.app") {
|
|
Section {
|
|
Link(destination: url) {
|
|
Image("PoweredByVoltplan")
|
|
.renderingMode(.original)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxWidth: 220)
|
|
.padding(.vertical, 20)
|
|
.frame(maxWidth: .infinity)
|
|
.accessibilityLabel("Powered by Voltplan")
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
|
}
|
|
.textCase(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ComponentRow: View {
|
|
let item: ComponentLibraryItem
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
iconView
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.localizedName)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
detailLine
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption)
|
|
.foregroundColor(Color(.tertiaryLabel))
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
private var iconView: some View {
|
|
LoadIconView(
|
|
remoteIconURLString: item.iconURL?.absoluteString,
|
|
fallbackSystemName: "bolt",
|
|
fallbackColor: Color.blue.opacity(0.15),
|
|
size: 44
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detailLine: some View {
|
|
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
|
|
|
|
if labels.isEmpty {
|
|
Text("Details coming soon")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
Text(labels.joined(separator: " • "))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|