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 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")

View File

@@ -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,
) )

View File

@@ -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

View File

@@ -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,22 +55,20 @@ 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) }
/** 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"))
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. */ /** 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) { fun select(item: ComponentLibraryItem, targetSystemId: String?, onDone: (String?) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val systemId: String val (systemId, createdNewSystem) = ensureSystem(targetSystemId)
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 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)
@@ -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
}
}
} }

View File

@@ -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

View File

@@ -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,9 +110,17 @@ 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) {
if (navigateId != null) onOpenSystem(navigateId) else onBack() 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 @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,