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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user