Files
Cable/Cable/Loads/ComponentLibraryView.swift
Stefan Lange-Hegermann 67ec44e60a Refine component library (iOS + Android)
Tweaks to the PocketBase-backed component library: item parsing,
repository/view-model fetching, and the library screen UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:04:00 +02:00

629 lines
21 KiB
Swift

import SwiftUI
enum ComponentLibraryType: String, Identifiable, CaseIterable {
case load
case battery
case charger
var id: String { rawValue }
/// PocketBase filter expression selecting this type.
var filterValue: String { "type='\(rawValue)'" }
}
struct ComponentLibraryItem: Identifiable, Equatable {
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 componentCategory: String?
let iconURL: URL?
var displayVoltage: Double? {
voltageIn ?? voltageOut
}
var current: Double? {
guard let power = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
return power / voltage
}
/// Battery capacity derived from stored energy (Wh) and nominal voltage.
var capacityAmpHours: Double? {
guard let energy = watt, let voltage = displayVoltage, voltage > 0 else { return nil }
return energy / voltage
}
/// Charger output current derived from rated power and output voltage.
var outputCurrent: Double? {
guard let power = watt, let voltage = voltageOut ?? displayVoltage, voltage > 0 else { return nil }
return power / voltage
}
var capacityLabel: String? {
guard let capacity = capacityAmpHours else { return nil }
return String(format: "%.0fAh", capacity)
}
var energyLabel: String? {
guard let energy = watt else { return nil }
return String(format: "%.0fWh", energy)
}
var voltageRangeLabel: String? {
if let input = voltageIn, let output = voltageOut {
return String(format: "%.0fV → %.0fV", input, output)
}
return voltageLabel
}
var outputCurrentLabel: String? {
guard let current = outputCurrent else { return nil }
return String(format: "%.1fA", current)
}
/// Detail metrics shown in a library row, tailored to the component type.
func detailLabels(for type: ComponentLibraryType) -> [String] {
switch type {
case .load:
return [voltageLabel, powerLabel, currentLabel].compactMap { $0 }
case .battery:
return [voltageLabel, capacityLabel, energyLabel].compactMap { $0 }
case .charger:
return [voltageRangeLabel, outputCurrentLabel, powerLabel].compactMap { $0 }
}
}
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)
}
func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name
}
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
let libraryType: ComponentLibraryType
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
self.libraryType = libraryType
self.urlSession = urlSession
}
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
self.libraryType = libraryType
self.urlSession = .shared
self.items = previewItems
self.isLoading = false
}
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: "(\(libraryType.filterValue))"),
URLQueryItem(name: "sort", value: "+name"),
URLQueryItem(
name: "fields",
value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category"
),
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 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,
componentCategory: record.componentCategory,
iconURL: iconURL(for: record)
)
}
for item in mappedItems {
if let url = item.iconURL {
Task.detached(priority: .background) {
await IconCache.shared.prefetch(url)
}
}
}
return mappedItems
}
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?
let componentCategory: String?
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"
case componentCategory = "component_category"
}
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 })
}
}
}
}
}
struct ComponentLibraryView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ComponentLibraryViewModel
@State private var searchText: String = ""
private let libraryType: ComponentLibraryType
let onSelect: (ComponentLibraryItem) -> Void
init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = libraryType
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
self.onSelect = onSelect
}
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
self.libraryType = viewModel.libraryType
self._viewModel = StateObject(wrappedValue: viewModel)
self.onSelect = onSelect
}
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, libraryType: libraryType)
}
.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
let libraryType: ComponentLibraryType
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: fallbackIcon,
fallbackColor: Color.blue.opacity(0.15),
size: 44
)
}
private var fallbackIcon: String {
switch libraryType {
case .load: return "bolt"
case .battery: return "battery.100"
case .charger: return "bolt.fill"
}
}
@ViewBuilder
private var detailLine: some View {
let labels = item.detailLabels(for: libraryType)
if labels.isEmpty {
Text("Details coming soon")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text(labels.joined(separator: ""))
.font(.caption)
.foregroundColor(.secondary)
}
}
}