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
|
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 {
|
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let name: String
|
let name: String
|
||||||
@@ -9,6 +20,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
|||||||
let watt: Double?
|
let watt: Double?
|
||||||
let dutyCyclePercent: Double?
|
let dutyCyclePercent: Double?
|
||||||
let defaultUtilizationFactorPercent: Double?
|
let defaultUtilizationFactorPercent: Double?
|
||||||
|
let componentCategory: String?
|
||||||
let iconURL: URL?
|
let iconURL: URL?
|
||||||
|
|
||||||
var displayVoltage: Double? {
|
var displayVoltage: Double? {
|
||||||
@@ -20,6 +32,52 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
|||||||
return power / voltage
|
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? {
|
var voltageLabel: String? {
|
||||||
guard let voltage = displayVoltage else { return nil }
|
guard let voltage = displayVoltage else { return nil }
|
||||||
return String(format: "%.1fV", voltage)
|
return String(format: "%.1fV", voltage)
|
||||||
@@ -173,12 +231,15 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
|
|
||||||
private let baseURL = URL(string: "https://base.voltplan.app")!
|
private let baseURL = URL(string: "https://base.voltplan.app")!
|
||||||
private let urlSession: URLSession
|
private let urlSession: URLSession
|
||||||
|
let libraryType: ComponentLibraryType
|
||||||
|
|
||||||
init(urlSession: URLSession = .shared) {
|
init(libraryType: ComponentLibraryType = .load, urlSession: URLSession = .shared) {
|
||||||
|
self.libraryType = libraryType
|
||||||
self.urlSession = urlSession
|
self.urlSession = urlSession
|
||||||
}
|
}
|
||||||
|
|
||||||
init(previewItems: [ComponentLibraryItem]) {
|
init(previewItems: [ComponentLibraryItem], libraryType: ComponentLibraryType = .load) {
|
||||||
|
self.libraryType = libraryType
|
||||||
self.urlSession = .shared
|
self.urlSession = .shared
|
||||||
self.items = previewItems
|
self.items = previewItems
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
@@ -216,11 +277,11 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
resolvingAgainstBaseURL: false
|
resolvingAgainstBaseURL: false
|
||||||
)
|
)
|
||||||
components?.queryItems = [
|
components?.queryItems = [
|
||||||
URLQueryItem(name: "filter", value: "(type='load')"),
|
URLQueryItem(name: "filter", value: "(\(libraryType.filterValue))"),
|
||||||
URLQueryItem(name: "sort", value: "+name"),
|
URLQueryItem(name: "sort", value: "+name"),
|
||||||
URLQueryItem(
|
URLQueryItem(
|
||||||
name: "fields",
|
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: "page", value: "\(page)"),
|
||||||
URLQueryItem(name: "perPage", value: "\(perPage)")
|
URLQueryItem(name: "perPage", value: "\(perPage)")
|
||||||
@@ -266,6 +327,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
watt: record.watt,
|
watt: record.watt,
|
||||||
dutyCyclePercent: record.dutyCycle,
|
dutyCyclePercent: record.dutyCycle,
|
||||||
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
||||||
|
componentCategory: record.componentCategory,
|
||||||
iconURL: iconURL(for: record)
|
iconURL: iconURL(for: record)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -312,6 +374,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
let watt: Double?
|
let watt: Double?
|
||||||
let dutyCycle: Double?
|
let dutyCycle: Double?
|
||||||
let defaultUtilizationFactor: Double?
|
let defaultUtilizationFactor: Double?
|
||||||
|
let componentCategory: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id
|
case id
|
||||||
@@ -324,6 +387,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
|||||||
case watt
|
case watt
|
||||||
case dutyCycle = "duty_cycle"
|
case dutyCycle = "duty_cycle"
|
||||||
case defaultUtilizationFactor = "default_utilization_factor"
|
case defaultUtilizationFactor = "default_utilization_factor"
|
||||||
|
case componentCategory = "component_category"
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TranslationsContainer: Decodable {
|
struct TranslationsContainer: Decodable {
|
||||||
@@ -385,14 +449,17 @@ struct ComponentLibraryView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel: ComponentLibraryViewModel
|
@StateObject private var viewModel: ComponentLibraryViewModel
|
||||||
@State private var searchText: String = ""
|
@State private var searchText: String = ""
|
||||||
|
private let libraryType: ComponentLibraryType
|
||||||
let onSelect: (ComponentLibraryItem) -> Void
|
let onSelect: (ComponentLibraryItem) -> Void
|
||||||
|
|
||||||
init(onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
init(libraryType: ComponentLibraryType = .load, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||||
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel())
|
self.libraryType = libraryType
|
||||||
|
self._viewModel = StateObject(wrappedValue: ComponentLibraryViewModel(libraryType: libraryType))
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
}
|
}
|
||||||
|
|
||||||
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
init(viewModel: ComponentLibraryViewModel, onSelect: @escaping (ComponentLibraryItem) -> Void) {
|
||||||
|
self.libraryType = viewModel.libraryType
|
||||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||||
self.onSelect = onSelect
|
self.onSelect = onSelect
|
||||||
}
|
}
|
||||||
@@ -463,7 +530,7 @@ struct ComponentLibraryView: View {
|
|||||||
onSelect(item)
|
onSelect(item)
|
||||||
dismiss()
|
dismiss()
|
||||||
} label: {
|
} label: {
|
||||||
ComponentRow(item: item)
|
ComponentRow(item: item, libraryType: libraryType)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -508,6 +575,7 @@ struct ComponentLibraryView: View {
|
|||||||
|
|
||||||
private struct ComponentRow: View {
|
private struct ComponentRow: View {
|
||||||
let item: ComponentLibraryItem
|
let item: ComponentLibraryItem
|
||||||
|
let libraryType: ComponentLibraryType
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -529,15 +597,23 @@ private struct ComponentRow: View {
|
|||||||
private var iconView: some View {
|
private var iconView: some View {
|
||||||
LoadIconView(
|
LoadIconView(
|
||||||
remoteIconURLString: item.iconURL?.absoluteString,
|
remoteIconURLString: item.iconURL?.absoluteString,
|
||||||
fallbackSystemName: "bolt",
|
fallbackSystemName: fallbackIcon,
|
||||||
fallbackColor: Color.blue.opacity(0.15),
|
fallbackColor: Color.blue.opacity(0.15),
|
||||||
size: 44
|
size: 44
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var fallbackIcon: String {
|
||||||
|
switch libraryType {
|
||||||
|
case .load: return "bolt"
|
||||||
|
case .battery: return "battery.100"
|
||||||
|
case .charger: return "bolt.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var detailLine: some View {
|
private var detailLine: some View {
|
||||||
let labels = [item.voltageLabel, item.powerLabel, item.currentLabel].compactMap { $0 }
|
let labels = item.detailLabels(for: libraryType)
|
||||||
|
|
||||||
if labels.isEmpty {
|
if labels.isEmpty {
|
||||||
Text("Details coming soon")
|
Text("Details coming soon")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ data class ComponentLibraryItem(
|
|||||||
val watt: Double?,
|
val watt: Double?,
|
||||||
val dutyCyclePercent: Double?,
|
val dutyCyclePercent: Double?,
|
||||||
val defaultUtilizationFactorPercent: Double?,
|
val defaultUtilizationFactorPercent: Double?,
|
||||||
|
val componentCategory: String?,
|
||||||
val iconURL: String?,
|
val iconURL: String?,
|
||||||
val affiliateLinks: List<AffiliateLink>,
|
val affiliateLinks: List<AffiliateLink>,
|
||||||
) {
|
) {
|
||||||
@@ -32,11 +33,43 @@ data class ComponentLibraryItem(
|
|||||||
return if (v > 0) w / v else null
|
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 localizedName: String get() = resolveLocalizedName(Locale.getDefault()) ?: name
|
||||||
|
|
||||||
val voltageLabel: String? get() = displayVoltage?.let { String.format(Locale.US, "%.1fV", it) }
|
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 powerLabel: String? get() = watt?.let { String.format(Locale.US, "%.0fW", it) }
|
||||||
val currentLabel: String? get() = current?.let { String.format(Locale.US, "%.1fA", 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)
|
val normalizedDutyCyclePercent: Double? get() = normalizePercent(dutyCyclePercent)
|
||||||
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
|
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
|
||||||
@@ -99,6 +132,7 @@ data class ComponentLibraryItem(
|
|||||||
watt = record.watt,
|
watt = record.watt,
|
||||||
dutyCyclePercent = record.dutyCycle,
|
dutyCyclePercent = record.dutyCycle,
|
||||||
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
|
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
|
||||||
|
componentCategory = record.componentCategory,
|
||||||
iconURL = iconUrl,
|
iconURL = iconUrl,
|
||||||
affiliateLinks = affiliateLinks,
|
affiliateLinks = affiliateLinks,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ package app.voltplan.cable.library
|
|||||||
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
|
/** Fetches and assembles the component library from PocketBase. Mirrors `ComponentLibraryViewModel` data flow. */
|
||||||
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
|
class ComponentLibraryRepository(private val api: PocketBaseApi = PocketBaseApi.create()) {
|
||||||
|
|
||||||
suspend fun fetchAll(): List<ComponentLibraryItem> {
|
suspend fun fetchAll(type: ComponentLibraryType = ComponentLibraryType.LOAD): List<ComponentLibraryItem> {
|
||||||
val records = fetchComponents()
|
val records = fetchComponents(type)
|
||||||
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
|
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
|
||||||
return records.map { record ->
|
return records.map { record ->
|
||||||
ComponentLibraryItem.from(record, affiliateByComponent[record.id].orEmpty())
|
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>()
|
val all = mutableListOf<PbComponentRecord>()
|
||||||
var page = 1
|
var page = 1
|
||||||
val perPage = 200
|
val perPage = 200
|
||||||
while (true) {
|
while (true) {
|
||||||
val response = api.components(page = page, perPage = perPage)
|
val response = api.components(filter = type.filter, page = page, perPage = perPage)
|
||||||
all += response.items
|
all += response.items
|
||||||
val done = (response.totalPages in 1..page) || response.items.size < perPage
|
val done = (response.totalPages in 1..page) || response.items.size < perPage
|
||||||
if (done) break
|
if (done) break
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
import app.voltplan.cable.analytics.Analytics
|
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.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.data.model.SavedLoad
|
||||||
import app.voltplan.cable.ui.systems.SystemIconMapper
|
import app.voltplan.cable.ui.systems.SystemIconMapper
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -30,6 +34,7 @@ data class LibraryUiState(
|
|||||||
|
|
||||||
class ComponentLibraryViewModel(
|
class ComponentLibraryViewModel(
|
||||||
private val app: CableApplication,
|
private val app: CableApplication,
|
||||||
|
val libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
|
||||||
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
|
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val repo = app.repository
|
private val repo = app.repository
|
||||||
@@ -41,7 +46,7 @@ class ComponentLibraryViewModel(
|
|||||||
fun load() {
|
fun load() {
|
||||||
_state.value = _state.value.copy(loading = true, error = null)
|
_state.value = _state.value.copy(loading = true, error = null)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { libraryRepo.fetchAll() }
|
runCatching { libraryRepo.fetchAll(libraryType) }
|
||||||
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
|
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
|
||||||
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
|
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
|
||||||
}
|
}
|
||||||
@@ -50,23 +55,21 @@ class ComponentLibraryViewModel(
|
|||||||
fun refresh() = load()
|
fun refresh() = load()
|
||||||
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
|
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. */
|
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */
|
||||||
fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
|
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
|
||||||
viewModelScope.launch {
|
if (targetSystemId != null) return targetSystemId to false
|
||||||
val systemId: String
|
|
||||||
val createdNewSystem: Boolean
|
|
||||||
if (targetSystemId != null) {
|
|
||||||
systemId = targetSystemId
|
|
||||||
createdNewSystem = false
|
|
||||||
} else {
|
|
||||||
val name = repo.uniqueSystemName("New System")
|
val name = repo.uniqueSystemName("New System")
|
||||||
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
|
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
|
||||||
repo.upsertSystem(system)
|
repo.upsertSystem(system)
|
||||||
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
|
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
|
||||||
systemId = system.id
|
return system.id to true
|
||||||
createdNewSystem = 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 baseName = item.localizedName.ifBlank { "Library Load" }
|
||||||
val loadName = repo.uniqueComponentName(systemId, baseName)
|
val loadName = repo.uniqueComponentName(systemId, baseName)
|
||||||
val voltage = item.displayVoltage ?: 12.0
|
val voltage = item.displayVoltage ?: 12.0
|
||||||
@@ -97,4 +100,79 @@ class ComponentLibraryViewModel(
|
|||||||
onDone(if (createdNewSystem) systemId else null)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ import retrofit2.http.Query
|
|||||||
|
|
||||||
const val POCKETBASE_BASE = "https://base.voltplan.app"
|
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
|
@Serializable
|
||||||
data class PbComponentsResponse(
|
data class PbComponentsResponse(
|
||||||
val page: Int = 1,
|
val page: Int = 1,
|
||||||
@@ -33,6 +48,7 @@ data class PbComponentRecord(
|
|||||||
val watt: Double? = null,
|
val watt: Double? = null,
|
||||||
@SerialName("duty_cycle") val dutyCycle: Double? = null,
|
@SerialName("duty_cycle") val dutyCycle: Double? = null,
|
||||||
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
|
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
|
||||||
|
@SerialName("component_category") val componentCategory: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -55,7 +71,7 @@ interface PocketBaseApi {
|
|||||||
suspend fun components(
|
suspend fun components(
|
||||||
@Query("filter") filter: String = "type='load'",
|
@Query("filter") filter: String = "type='load'",
|
||||||
@Query("sort") sort: String = "+name",
|
@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("page") page: Int,
|
||||||
@Query("perPage") perPage: Int = 200,
|
@Query("perPage") perPage: Int = 200,
|
||||||
): PbComponentsResponse
|
): PbComponentsResponse
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import app.voltplan.cable.CableApplication
|
|||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.library.ComponentLibraryItem
|
import app.voltplan.cable.library.ComponentLibraryItem
|
||||||
|
import app.voltplan.cable.library.ComponentLibraryType
|
||||||
import app.voltplan.cable.library.ComponentLibraryViewModel
|
import app.voltplan.cable.library.ComponentLibraryViewModel
|
||||||
import app.voltplan.cable.ui.components.LoadIcon
|
import app.voltplan.cable.ui.components.LoadIcon
|
||||||
import app.voltplan.cable.ui.theme.SysBlue
|
import app.voltplan.cable.ui.theme.SysBlue
|
||||||
@@ -51,18 +52,28 @@ import app.voltplan.cable.ui.theme.SysOrange
|
|||||||
@Composable
|
@Composable
|
||||||
fun ComponentLibraryScreen(
|
fun ComponentLibraryScreen(
|
||||||
targetSystemId: String?,
|
targetSystemId: String?,
|
||||||
|
libraryType: ComponentLibraryType = ComponentLibraryType.LOAD,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenSystem: (String) -> Unit,
|
onOpenSystem: (String) -> Unit,
|
||||||
|
onOpenBatteryEditor: (String, String) -> Unit = { _, _ -> },
|
||||||
|
onOpenChargerEditor: (String, String) -> Unit = { _, _ -> },
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val app = context.applicationContext as CableApplication
|
val app = context.applicationContext as CableApplication
|
||||||
val vm: ComponentLibraryViewModel = viewModel(
|
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()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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(
|
Scaffold(
|
||||||
@@ -99,10 +110,18 @@ fun ComponentLibraryScreen(
|
|||||||
}
|
}
|
||||||
else -> LazyColumn(Modifier.fillMaxSize()) {
|
else -> LazyColumn(Modifier.fillMaxSize()) {
|
||||||
items(state.filtered, key = { it.id }) { item ->
|
items(state.filtered, key = { it.id }) { item ->
|
||||||
LibraryRow(item) {
|
LibraryRow(item, libraryType) {
|
||||||
vm.select(item, targetSystemId) { navigateId ->
|
when (libraryType) {
|
||||||
|
ComponentLibraryType.LOAD -> vm.select(item, targetSystemId) { navigateId ->
|
||||||
if (navigateId != null) onOpenSystem(navigateId) else onBack()
|
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
|
@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) {
|
Surface(modifier = Modifier.fillMaxWidth(), onClick = onClick, color = MaterialTheme.colorScheme.surface) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
LoadIcon(item.iconURL, "bolt", SysBlue, 44.dp)
|
LoadIcon(item.iconURL, fallbackIcon, SysBlue, 44.dp)
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(item.localizedName, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.titleSmall)
|
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(
|
Text(
|
||||||
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(" • "),
|
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(" • "),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
|||||||
Reference in New Issue
Block a user