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>
This commit is contained in:
2026-06-04 01:04:00 +02:00
parent 0aa3184406
commit 67ec44e60a
6 changed files with 265 additions and 37 deletions

View File

@@ -1,5 +1,16 @@
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
@@ -9,6 +20,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let watt: Double?
let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double?
let componentCategory: String?
let iconURL: URL?
var displayVoltage: Double? {
@@ -19,7 +31,53 @@ struct ComponentLibraryItem: Identifiable, Equatable {
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)
@@ -173,12 +231,15 @@ final class ComponentLibraryViewModel: ObservableObject {
private let baseURL = URL(string: "https://base.voltplan.app")!
private let urlSession: URLSession
let libraryType: ComponentLibraryType
init(urlSession: URLSession = .shared) {
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
self.libraryType = libraryType
self.urlSession = urlSession
}
init(previewItems: [ComponentLibraryItem]) {
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
self.libraryType = libraryType
self.urlSession = .shared
self.items = previewItems
self.isLoading = false
@@ -216,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"),
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"
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)")
@@ -266,6 +327,7 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt,
dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
componentCategory: record.componentCategory,
iconURL: iconURL(for: record)
)
}
@@ -312,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
let watt: Double?
let dutyCycle: Double?
let defaultUtilizationFactor: Double?
let componentCategory: String?
enum CodingKeys: String, CodingKey {
case id
@@ -324,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
case watt
case dutyCycle = "duty_cycle"
case defaultUtilizationFactor = "default_utilization_factor"
case componentCategory = "component_category"
}
struct TranslationsContainer: Decodable {
@@ -385,14 +449,17 @@ 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(onSelect: @escaping (ComponentLibraryItem) -> Void) {
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
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
}
@@ -463,7 +530,7 @@ struct ComponentLibraryView: View {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
ComponentRow(item: item, libraryType: libraryType)
}
.buttonStyle(.plain)
}
@@ -508,6 +575,7 @@ struct ComponentLibraryView: View {
private struct ComponentRow: View {
let item: ComponentLibraryItem
let libraryType: ComponentLibraryType
var body: some View {
HStack(spacing: 12) {
@@ -529,15 +597,23 @@ private struct ComponentRow: View {
private var iconView: some View {
LoadIconView(
remoteIconURLString: item.iconURL?.absoluteString,
fallbackSystemName: "bolt",
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.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
let labels = item.detailLabels(for: libraryType)
if labels.isEmpty {
Text("Details coming soon")