Refine component library (iOS + Android)
Tweaks to the PocketBase-backed component library: item parsing, repository/view-model fetching, and the library screen UI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ComponentLibraryType: String, Identifiable, CaseIterable {
|
||||
case load
|
||||
case battery
|
||||
case charger
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// PocketBase filter expression selecting this type.
|
||||
var filterValue: String { "type='\(rawValue)'" }
|
||||
}
|
||||
|
||||
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
let id: String
|
||||
let name: String
|
||||
@@ -9,6 +20,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
let watt: Double?
|
||||
let dutyCyclePercent: Double?
|
||||
let defaultUtilizationFactorPercent: Double?
|
||||
let componentCategory: String?
|
||||
let iconURL: URL?
|
||||
|
||||
var displayVoltage: Double? {
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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. */
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user