Files
Cable/Cable/ComponentLibraryView.swift
2025-10-13 09:38:22 +02:00

466 lines
15 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 voltageIn: Double?
let voltageOut: Double?
let watt: 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 primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
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
}
}
@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,icon,voltage_in,voltage_out,watt"),
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,
voltageIn: record.voltageIn,
voltageOut: record.voltageOut,
watt: record.watt,
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 icon: String?
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
enum CodingKeys: String, CodingKey {
case id
case collectionId
case name
case icon
case voltageIn = "voltage_in"
case voltageOut = "voltage_out"
case watt
}
}
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(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
}
.buttonStyle(.plain)
}
.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
item.name.localizedCaseInsensitiveContains(trimmedQuery)
}
}
}
private struct ComponentRow: View {
let item: ComponentLibraryItem
var body: some View {
HStack(spacing: 12) {
iconView
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.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)
}
}
}