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