diff --git a/Cable/Loads/ComponentLibraryView.swift b/Cable/Loads/ComponentLibraryView.swift index 661e59d..cbd7ce7 100644 --- a/Cable/Loads/ComponentLibraryView.swift +++ b/Cable/Loads/ComponentLibraryView.swift @@ -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") diff --git a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt index f058d3e..c0c5617 100644 --- a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt +++ b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt @@ -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, ) { @@ -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 = 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, ) diff --git a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt index ebfcee3..bd26244 100644 --- a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt +++ b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt @@ -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 { - val records = fetchComponents() + suspend fun fetchAll(type: ComponentLibraryType = ComponentLibraryType.LOAD): List { + 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 { + private suspend fun fetchComponents(type: ComponentLibraryType): List { val all = mutableListOf() 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 diff --git a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt index 38c7548..f6ab835 100644 --- a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt +++ b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt @@ -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,22 +55,20 @@ class ComponentLibraryViewModel( fun refresh() = load() fun setQuery(q: String) { _state.value = _state.value.copy(query = q) } + /** Returns the system to add into, creating a new one when [targetSystemId] is null. */ + private suspend fun ensureSystem(targetSystemId: String?): Pair { + 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")) + 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: String - val createdNewSystem: Boolean - if (targetSystemId != null) { - systemId = targetSystemId - createdNewSystem = false - } else { - 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 - } + val (systemId, createdNewSystem) = ensureSystem(targetSystemId) val baseName = item.localizedName.ifBlank { "Library Load" } val loadName = repo.uniqueComponentName(systemId, baseName) @@ -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 + } + } } diff --git a/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt b/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt index ea0c424..0a36350 100644 --- a/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt +++ b/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt @@ -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 diff --git a/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt index 7a3e501..2b81253 100644 --- a/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt +++ b/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt @@ -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,9 +110,17 @@ fun ComponentLibraryScreen( } else -> LazyColumn(Modifier.fillMaxSize()) { items(state.filtered, key = { it.id }) { item -> - LibraryRow(item) { - vm.select(item, targetSystemId) { navigateId -> - if (navigateId != null) onOpenSystem(navigateId) else onBack() + 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,