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? {
@@ -20,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
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")

View File

@@ -20,6 +20,7 @@ data class ComponentLibraryItem(
val watt: Double?,
val dutyCyclePercent: Double?,
val defaultUtilizationFactorPercent: Double?,
val componentCategory: String?,
val iconURL: String?,
val affiliateLinks: List<AffiliateLink>,
) {
@@ -32,11 +33,43 @@ data class ComponentLibraryItem(
return if (v > 0) w / v else null
}
/** Battery capacity derived from stored energy (Wh) and nominal voltage. */
val capacityAmpHours: Double?
get() {
val v = displayVoltage ?: return null
val w = watt ?: return null
return if (v > 0) w / v else null
}
/** Charger output current derived from rated power and output voltage. */
val outputCurrent: Double?
get() {
val v = voltageOut ?: displayVoltage ?: return null
val w = watt ?: return null
return if (v > 0) w / v else null
}
val localizedName: String get() = resolveLocalizedName(Locale.getDefault()) ?: name
val voltageLabel: String? get() = displayVoltage?.let { String.format(Locale.US, "%.1fV", it) }
val powerLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fW", it) }
val currentLabel: String? get() = current?.let { String.format(Locale.US, "%.1fA", it) }
val capacityLabel: String? get() = capacityAmpHours?.let { String.format(Locale.US, "%.0fAh", it) }
val energyLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fWh", it) }
val voltageRangeLabel: String?
get() = if (voltageIn != null && voltageOut != null) {
String.format(Locale.US, "%.0fV → %.0fV", voltageIn, voltageOut)
} else {
voltageLabel
}
val outputCurrentLabel: String? get() = outputCurrent?.let { String.format(Locale.US, "%.1fA", it) }
/** Detail metrics shown in a library row, tailored to the component type. */
fun detailLabels(type: ComponentLibraryType): List<String> = when (type) {
ComponentLibraryType.LOAD -> listOfNotNull(voltageLabel, powerLabel, currentLabel)
ComponentLibraryType.BATTERY -> listOfNotNull(voltageLabel, capacityLabel, energyLabel)
ComponentLibraryType.CHARGER -> listOfNotNull(voltageRangeLabel, outputCurrentLabel, powerLabel)
}
val normalizedDutyCyclePercent: Double? get() = normalizePercent(dutyCyclePercent)
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
@@ -99,6 +132,7 @@ data class ComponentLibraryItem(
watt = record.watt,
dutyCyclePercent = record.dutyCycle,
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
componentCategory = record.componentCategory,
iconURL = iconUrl,
affiliateLinks = affiliateLinks,
)

View File

@@ -3,20 +3,20 @@ package app.voltplan.cable.library
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
suspend fun fetchAll(): List<ComponentLibraryItem> {
val records = fetchComponents()
suspend fun fetchAll(type: ComponentLibraryType = ComponentLibraryType.LOAD): List<ComponentLibraryItem> {
val records = fetchComponents(type)
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
return records.map { record ->
ComponentLibraryItem.from(record, affiliateByComponent[record.id].orEmpty())
}
}
private suspend fun fetchComponents(): List<PbComponentRecord> {
private suspend fun fetchComponents(type: ComponentLibraryType): List<PbComponentRecord> {
val all = mutableListOf<PbComponentRecord>()
var page = 1
val perPage = 200
while (true) {
val response = api.components(page = page, perPage = perPage)
val response = api.components(filter = type.filter, page = page, perPage = perPage)
all += response.items
val done = (response.totalPages in 1..page) || response.items.size < perPage
if (done) break

View File

@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.Chemistry
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.PowerSourceType
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.SavedLoad
import app.voltplan.cable.ui.systems.SystemIconMapper
import kotlinx.coroutines.flow.MutableStateFlow
@@ -30,6 +34,7 @@ data class LibraryUiState(
class ComponentLibraryViewModel(
private val app: CableApplication,
val libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
) : ViewModel() {
private val repo = app.repository
@@ -41,7 +46,7 @@ class ComponentLibraryViewModel(
fun load() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
runCatching { libraryRepo.fetchAll() }
runCatching { libraryRepo.fetchAll(libraryType) }
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
}
@@ -50,23 +55,21 @@ class ComponentLibraryViewModel(
fun refresh() = load()
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
viewModelScope.launch {
val systemId: String
val createdNewSystem: Boolean
if (targetSystemId != null) {
systemId = targetSystemId
createdNewSystem = false
} else {
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
if (targetSystemId != null) return targetSystemId to false
val name = repo.uniqueSystemName("New System")
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
repo.upsertSystem(system)
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
systemId = system.id
createdNewSystem = true
return system.id to true
}
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
viewModelScope.launch {
val (systemId, createdNewSystem) = ensureSystem(targetSystemId)
val baseName = item.localizedName.ifBlank { "Library Load" }
val loadName = repo.uniqueComponentName(systemId, baseName)
val voltage = item.displayVoltage ?: 12.0
@@ -97,4 +100,79 @@ class ComponentLibraryViewModel(
onDone(if (createdNewSystem) systemId else null)
}
}
/** Adds the chosen component as a battery, then opens its editor via [onDone] (systemId, batteryId). */
fun selectBattery(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String, String) -> Unit) {
viewModelScope.launch {
val (systemId, _) = ensureSystem(targetSystemId)
val baseName = item.localizedName.ifBlank { "New Battery" }
val name = repo.uniqueComponentName(systemId, baseName)
val voltage = item.displayVoltage ?: 12.8
val capacity = item.capacityAmpHours ?: 100.0
val affiliate = item.primaryAffiliateLink
val battery = SavedBattery(
name = name,
nominalVoltage = voltage,
capacityAmpHours = capacity,
chemistryRawValue = Chemistry.LIFEPO4.rawValue,
iconName = "battery.100",
colorName = "blue",
systemId = systemId,
affiliateURLString = affiliate?.url,
affiliateCountryCode = affiliate?.country,
)
repo.upsertBattery(battery)
Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
onDone(systemId, battery.id)
}
}
/** Adds the chosen component as a charger, then opens its editor via [onDone] (systemId, chargerId). */
fun selectCharger(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String, String) -> Unit) {
viewModelScope.launch {
val (systemId, _) = ensureSystem(targetSystemId)
val baseName = item.localizedName.ifBlank { "New Charger" }
val name = repo.uniqueComponentName(systemId, baseName)
val inputVoltage = item.voltageIn ?: 230.0
val outputVoltage = item.voltageOut ?: 14.2
val power = item.watt ?: 0.0
val current = item.outputCurrent ?: if (outputVoltage > 0) power / outputVoltage else 30.0
val sourceType = chargerSourceType(item.componentCategory)
val affiliate = item.primaryAffiliateLink
val charger = SavedCharger(
name = name,
inputVoltage = inputVoltage,
outputVoltage = outputVoltage,
maxCurrentAmps = current,
maxPowerWatts = power,
iconName = sourceType.iconName,
colorName = "orange",
systemId = systemId,
remoteIconURLString = item.iconURL,
affiliateURLString = affiliate?.url,
affiliateCountryCode = affiliate?.country,
powerSourceType = sourceType.rawValue,
)
repo.upsertCharger(charger)
Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
onDone(systemId, charger.id)
}
}
/** Maps a PocketBase `component_category` to a charger power source. */
private fun chargerSourceType(category: String?): PowerSourceType {
val c = category?.lowercase()?.takeUnless { it.isBlank() } ?: return PowerSourceType.SHORE
return when {
"solar" in c -> PowerSourceType.SOLAR
"wind" in c -> PowerSourceType.WIND
"dcdc" in c || "alternator" in c -> PowerSourceType.ALTERNATOR
"generator" in c -> PowerSourceType.GENERATOR
"mains" in c || "shore" in c -> PowerSourceType.SHORE
else -> PowerSourceType.SHORE
}
}
}

View File

@@ -13,6 +13,21 @@ import retrofit2.http.Query
const val POCKETBASE_BASE = "https://base.voltplan.app"
/** The kind of library being browsed. Mirrors the iOS `ComponentLibraryType`. */
enum class ComponentLibraryType(val typeValue: String) {
LOAD("load"),
BATTERY("battery"),
CHARGER("charger");
/** PocketBase filter expression selecting this type. */
val filter: String get() = "type='$typeValue'"
companion object {
fun fromArg(value: String?): ComponentLibraryType =
entries.firstOrNull { it.typeValue == value } ?: LOAD
}
}
@Serializable
data class PbComponentsResponse(
val page: Int = 1,
@@ -33,6 +48,7 @@ data class PbComponentRecord(
val watt: Double? = null,
@SerialName("duty_cycle") val dutyCycle: Double? = null,
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
@SerialName("component_category") val componentCategory: String? = null,
)
@Serializable
@@ -55,7 +71,7 @@ interface PocketBaseApi {
suspend fun components(
@Query("filter") filter: String = "type='load'",
@Query("sort") sort: String = "+name",
@Query("fields") fields: String = "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor",
@Query("fields") fields: String = "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt,duty_cycle,default_utilization_factor,component_category",
@Query("page") page: Int,
@Query("perPage") perPage: Int = 200,
): PbComponentsResponse

View File

@@ -42,6 +42,7 @@ import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.library.ComponentLibraryItem
import app.voltplan.cable.library.ComponentLibraryType
import app.voltplan.cable.library.ComponentLibraryViewModel
import app.voltplan.cable.ui.components.LoadIcon
import app.voltplan.cable.ui.theme.SysBlue
@@ -51,18 +52,28 @@ import app.voltplan.cable.ui.theme.SysOrange
@Composable
fun ComponentLibraryScreen(
targetSystemId: String?,
libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
onBack: () -> Unit,
onOpenSystem: (String) -> Unit,
onOpenBatteryEditor: (String, String) -> Unit = { _, _ -> },
onOpenChargerEditor: (String, String) -> Unit = { _, _ -> },
) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: ComponentLibraryViewModel = viewModel(
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app) } },
key = "library-${libraryType.typeValue}",
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app, libraryType) } },
)
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
Analytics.log("Component Library Opened", mapOf("source" to if (targetSystemId != null) "system" else "systems-list"))
Analytics.log(
"Component Library Opened",
mapOf(
"source" to if (targetSystemId != null) "system" else "systems-list",
"type" to libraryType.typeValue,
),
)
}
Scaffold(
@@ -99,10 +110,18 @@ fun ComponentLibraryScreen(
}
else -> LazyColumn(Modifier.fillMaxSize()) {
items(state.filtered, key = { it.id }) { item ->
LibraryRow(item) {
vm.select(item, targetSystemId) { navigateId ->
LibraryRow(item, libraryType) {
when (libraryType) {
ComponentLibraryType.LOAD -> vm.select(item, targetSystemId) { navigateId ->
if (navigateId != null) onOpenSystem(navigateId) else onBack()
}
ComponentLibraryType.BATTERY -> vm.selectBattery(item, targetSystemId) { systemId, batteryId ->
onOpenBatteryEditor(systemId, batteryId)
}
ComponentLibraryType.CHARGER -> vm.selectCharger(item, targetSystemId) { systemId, chargerId ->
onOpenChargerEditor(systemId, chargerId)
}
}
}
}
}
@@ -121,17 +140,22 @@ private fun Centered(content: @Composable () -> Unit) {
}
@Composable
private fun LibraryRow(item: ComponentLibraryItem, onClick: () -> Unit) {
private fun LibraryRow(item: ComponentLibraryItem, libraryType: ComponentLibraryType, onClick: () -> Unit) {
val fallbackIcon = when (libraryType) {
ComponentLibraryType.LOAD -> "bolt"
ComponentLibraryType.BATTERY -> "battery.100"
ComponentLibraryType.CHARGER -> "bolt.fill"
}
Surface(modifier = Modifier.fillMaxWidth(), onClick = onClick, color = MaterialTheme.colorScheme.surface) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
LoadIcon(item.iconURL, "bolt", SysBlue, 44.dp)
LoadIcon(item.iconURL, fallbackIcon, SysBlue, 44.dp)
Column(Modifier.weight(1f)) {
Text(item.localizedName, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.titleSmall)
val details = listOfNotNull(item.voltageLabel, item.powerLabel, item.currentLabel)
val details = item.detailLabels(libraryType)
Text(
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(""),
style = MaterialTheme.typography.bodySmall,