Align Android with iOS reference behavior

- Overview PDF: usable energy summary, cable length, power loss,
  usable energy rows (matching iOS tables)
- Analytics: align event names and properties with iOS
- Localize default component names and editor dialog labels
- Unit system default via device measurement system (API 28+)
- Preserve BOM/affiliate fields when editing batteries/chargers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:36:18 +02:00
parent 89ee36c1a4
commit d4b490ea07
19 changed files with 191 additions and 43 deletions

View File

@@ -1,5 +1,8 @@
package app.voltplan.cable.data package app.voltplan.cable.data
import android.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import java.util.Locale import java.util.Locale
/** Metric (mm², m) or imperial (AWG, ft). Mirrors the iOS `UnitSystem` enum. */ /** Metric (mm², m) or imperial (AWG, ft). Mirrors the iOS `UnitSystem` enum. */
@@ -13,10 +16,16 @@ enum class UnitSystem(val rawValue: String) {
companion object { companion object {
fun fromRaw(raw: String?): UnitSystem? = entries.firstOrNull { it.rawValue == raw } fun fromRaw(raw: String?): UnitSystem? = entries.firstOrNull { it.rawValue == raw }
/** Default for the device locale — US measurement system implies imperial. */ /** Default for the device locale — mirrors iOS `Locale.measurementSystem == .us`. */
fun deviceDefault(): UnitSystem { fun deviceDefault(): UnitSystem {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val system = runCatching { LocaleData.getMeasurementSystem(ULocale.getDefault()) }.getOrNull()
if (system != null) {
return if (system == LocaleData.MeasurementSystem.US) IMPERIAL else METRIC
}
}
val country = Locale.getDefault().country.uppercase() val country = Locale.getDefault().country.uppercase()
// Countries that customarily use US/imperial measurements. // Fallback: countries that customarily use US/imperial measurements.
return if (country in setOf("US", "LR", "MM")) IMPERIAL else METRIC return if (country in setOf("US", "LR", "MM")) IMPERIAL else METRIC
} }
} }

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.data.model
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import app.voltplan.cable.data.LocaleDefaults
import java.util.UUID import java.util.UUID
/** /**
@@ -78,7 +79,7 @@ data class SavedBattery(
data class SavedCharger( data class SavedCharger(
@PrimaryKey val id: String = UUID.randomUUID().toString(), @PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String = "", val name: String = "",
val inputVoltage: Double = 230.0, val inputVoltage: Double = LocaleDefaults.mainsVoltage,
val outputVoltage: Double = 14.2, val outputVoltage: Double = 14.2,
val maxCurrentAmps: Double = 30.0, val maxCurrentAmps: Double = 30.0,
val maxPowerWatts: Double = 0.0, val maxPowerWatts: Double = 0.0,

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.library
import androidx.lifecycle.ViewModel 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.R
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.Chemistry
import app.voltplan.cable.data.model.ElectricalSystem import app.voltplan.cable.data.model.ElectricalSystem
@@ -55,10 +56,14 @@ 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) }
/** Resolves the display name of [systemId], or null when unknown. */
suspend fun systemName(systemId: String?): String? =
systemId?.let { repo.getSystem(it)?.name }
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */ /** Returns the system to add into, creating a new one when [targetSystemId] is null. */
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> { private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
if (targetSystemId != null) return targetSystemId to false if (targetSystemId != null) return targetSystemId to false
val name = repo.uniqueSystemName("New System") val name = repo.uniqueSystemName(app.getString(R.string.default_system_new))
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"))
@@ -95,7 +100,7 @@ class ComponentLibraryViewModel(
affiliateCountryCode = affiliate?.country, affiliateCountryCode = affiliate?.country,
) )
repo.upsertLoad(load) repo.upsertLoad(load)
Analytics.log("Library Load Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId)) Analytics.log("Library Load Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to (systemName(systemId) ?: "")))
onDone(if (createdNewSystem) systemId else null) onDone(if (createdNewSystem) systemId else null)
} }
@@ -123,7 +128,7 @@ class ComponentLibraryViewModel(
affiliateCountryCode = affiliate?.country, affiliateCountryCode = affiliate?.country,
) )
repo.upsertBattery(battery) repo.upsertBattery(battery)
Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId)) Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to (systemName(systemId) ?: "")))
onDone(systemId, battery.id) onDone(systemId, battery.id)
} }
@@ -157,7 +162,7 @@ class ComponentLibraryViewModel(
powerSourceType = sourceType.rawValue, powerSourceType = sourceType.rawValue,
) )
repo.upsertCharger(charger) repo.upsertCharger(charger)
Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId)) Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to (systemName(systemId) ?: "")))
onDone(systemId, charger.id) onDone(systemId, charger.id)
} }

View File

@@ -16,6 +16,7 @@ import app.voltplan.cable.data.model.chemistry
import app.voltplan.cable.data.model.effectivePowerWatts import app.voltplan.cable.data.model.effectivePowerWatts
import app.voltplan.cable.data.model.energyWattHours import app.voltplan.cable.data.model.energyWattHours
import app.voltplan.cable.data.model.usableCapacityAmpHours import app.voltplan.cable.data.model.usableCapacityAmpHours
import app.voltplan.cable.data.model.usableEnergyWattHours
import app.voltplan.cable.ui.system.DetailState import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.util.Fmt import app.voltplan.cable.util.Fmt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -45,7 +46,7 @@ object SystemOverviewPdf {
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargetime), m.estimatedChargeHours?.let { formatDurationHours(it) } ?: "—") summaryLine(w, context.getString(R.string.overview_pdf_summary_chargetime), m.estimatedChargeHours?.let { formatDurationHours(it) } ?: "—")
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalpower), "${Fmt.number(m.totalPower)} W") summaryLine(w, context.getString(R.string.overview_pdf_summary_totalpower), "${Fmt.number(m.totalPower)} W")
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalcurrent), "${Fmt.number(m.totalCurrent)} A") summaryLine(w, context.getString(R.string.overview_pdf_summary_totalcurrent), "${Fmt.number(m.totalCurrent)} A")
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalCapacity)} Ah") summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalUsableEnergy)} Wh")
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W") summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
// Full-page wiring diagram, followed by a fresh page for the entity tables. // Full-page wiring diagram, followed by a fresh page for the entity tables.
@@ -58,9 +59,11 @@ object SystemOverviewPdf {
val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit) val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit)
val gauge = if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", cs) else "${ElectricalCalculations.formatAWG(cs)} AWG" val gauge = if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", cs) else "${ElectricalCalculations.formatAWG(cs)} AWG"
val vdrop = ElectricalCalculations.voltageDropPercentage(load.length, load.current, load.voltage, unit) val vdrop = ElectricalCalculations.voltageDropPercentage(load.length, load.current, load.voltage, unit)
val ploss = ElectricalCalculations.powerLoss(load.length, load.current, load.voltage, unit)
summaryLine(w, "${context.getString(R.string.overview_pdf_load_voltage)} / ${context.getString(R.string.overview_pdf_load_current)}", String.format(Locale.US, "%.1f V / %.1f A", load.voltage, load.current)) summaryLine(w, "${context.getString(R.string.overview_pdf_load_voltage)} / ${context.getString(R.string.overview_pdf_load_current)}", String.format(Locale.US, "%.1f V / %.1f A", load.voltage, load.current))
summaryLine(w, "${context.getString(R.string.overview_pdf_load_power)} / ${context.getString(R.string.overview_pdf_load_cable)}", "${Fmt.number(load.power)} W / $gauge") summaryLine(w, "${context.getString(R.string.overview_pdf_load_power)} / ${context.getString(R.string.overview_pdf_load_length)}", "${Fmt.number(load.power)} W / ${Fmt.number(load.length)} ${unit.lengthUnit}")
summaryLine(w, "${context.getString(R.string.overview_pdf_load_vdrop)} / ${context.getString(R.string.overview_pdf_load_fuse)}", String.format(Locale.US, "%.1f%% / %.0f A", vdrop, ElectricalCalculations.recommendedFuse(load.current))) summaryLine(w, "${context.getString(R.string.overview_pdf_load_cable)} / ${context.getString(R.string.overview_pdf_load_vdrop)}", String.format(Locale.US, "%s / %.1f%%", gauge, vdrop))
summaryLine(w, "${context.getString(R.string.overview_pdf_load_ploss)} / ${context.getString(R.string.overview_pdf_load_fuse)}", String.format(Locale.US, "%s W / %.0f A", Fmt.number(ploss), ElectricalCalculations.recommendedFuse(load.current)))
w.divider() w.divider()
} }
} }
@@ -71,7 +74,7 @@ object SystemOverviewPdf {
w.text(b.name, 14f, bold = true) w.text(b.name, 14f, bold = true)
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_chemistry)} / ${context.getString(R.string.overview_pdf_battery_voltage)}", "${b.chemistry.displayName} / ${Fmt.number(b.nominalVoltage)} V") summaryLine(w, "${context.getString(R.string.overview_pdf_battery_chemistry)} / ${context.getString(R.string.overview_pdf_battery_voltage)}", "${b.chemistry.displayName} / ${Fmt.number(b.nominalVoltage)} V")
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_capacity)} / ${context.getString(R.string.overview_pdf_battery_usable)}", "${Fmt.number(b.capacityAmpHours)} Ah / ${Fmt.number(b.usableCapacityAmpHours)} Ah") summaryLine(w, "${context.getString(R.string.overview_pdf_battery_capacity)} / ${context.getString(R.string.overview_pdf_battery_usable)}", "${Fmt.number(b.capacityAmpHours)} Ah / ${Fmt.number(b.usableCapacityAmpHours)} Ah")
summaryLine(w, context.getString(R.string.overview_pdf_battery_energy), "${Fmt.number(b.energyWattHours)} Wh") summaryLine(w, "${context.getString(R.string.overview_pdf_battery_energy)} / ${context.getString(R.string.overview_pdf_battery_usableenergy)}", "${Fmt.number(b.energyWattHours)} Wh / ${Fmt.number(b.usableEnergyWattHours)} Wh")
w.divider() w.divider()
} }
} }

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.batteries
import androidx.lifecycle.ViewModel 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.R
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.Chemistry
import app.voltplan.cable.data.model.SavedBattery import app.voltplan.cable.data.model.SavedBattery
@@ -14,7 +15,7 @@ import java.util.UUID
import kotlin.math.abs import kotlin.math.abs
data class BatteryState( data class BatteryState(
val name: String = "New Battery", val name: String = "",
val chemistry: Chemistry = Chemistry.LIFEPO4, val chemistry: Chemistry = Chemistry.LIFEPO4,
val nominalVoltage: Double = 12.8, val nominalVoltage: Double = 12.8,
val capacityAmpHours: Double = 100.0, val capacityAmpHours: Double = 100.0,
@@ -33,7 +34,7 @@ data class BatteryState(
} }
class BatteryEditorViewModel( class BatteryEditorViewModel(
app: CableApplication, private val app: CableApplication,
private val systemId: String, private val systemId: String,
batteryId: String?, batteryId: String?,
) : ViewModel() { ) : ViewModel() {
@@ -41,14 +42,25 @@ class BatteryEditorViewModel(
private var id: String = batteryId ?: UUID.randomUUID().toString() private var id: String = batteryId ?: UUID.randomUUID().toString()
private val isNew = batteryId == null private val isNew = batteryId == null
private var loggedCreate = false private var loggedCreate = false
private var systemName: String = ""
// Fields the editor doesn't touch but must survive a save round-trip.
private var affiliateURLString: String? = null
private var affiliateCountryCode: String? = null
private var bomCompletedItemIDs: List<String> = emptyList()
private val _state = MutableStateFlow(BatteryState()) private val _state = MutableStateFlow(BatteryState())
val state: StateFlow<BatteryState> = _state.asStateFlow() val state: StateFlow<BatteryState> = _state.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
val system = repo.getSystem(systemId)
systemName = system?.name ?: ""
if (batteryId != null) { if (batteryId != null) {
repo.getBattery(batteryId)?.let { b -> repo.getBattery(batteryId)?.let { b ->
affiliateURLString = b.affiliateURLString
affiliateCountryCode = b.affiliateCountryCode
bomCompletedItemIDs = b.bomCompletedItemIDs
_state.value = BatteryState( _state.value = BatteryState(
name = b.name, name = b.name,
chemistry = Chemistry.fromRaw(b.chemistryRawValue), chemistry = Chemistry.fromRaw(b.chemistryRawValue),
@@ -64,14 +76,22 @@ class BatteryEditorViewModel(
) )
} }
} else { } else {
val color = repo.getSystem(systemId)?.colorName ?: "blue" val color = system?.colorName ?: "blue"
val name = repo.uniqueComponentName(systemId, "New Battery") val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_battery_new))
_state.value = _state.value.copy(name = name, colorName = color) _state.value = _state.value.copy(name = name, colorName = color)
} }
Analytics.log("Battery Editor Opened", mapOf("source" to if (isNew) "create" else "edit")) Analytics.log("Battery Editor Opened", mapOf("source" to if (isNew) "create" else "edit", "system" to systemName))
} }
} }
override fun onCleared() {
// Mirrors iOS, which logs "Battery Updated" when the editor for an existing battery closes.
if (!isNew) {
Analytics.log("Battery Updated", mapOf("name" to _state.value.name, "system" to systemName))
}
super.onCleared()
}
private fun update(transform: (BatteryState) -> BatteryState) { private fun update(transform: (BatteryState) -> BatteryState) {
_state.value = transform(_state.value) _state.value = transform(_state.value)
persist() persist()
@@ -120,13 +140,16 @@ class BatteryEditorViewModel(
iconName = s.iconName, iconName = s.iconName,
colorName = s.colorName, colorName = s.colorName,
systemId = systemId, systemId = systemId,
affiliateURLString = affiliateURLString,
affiliateCountryCode = affiliateCountryCode,
bomCompletedItemIDs = bomCompletedItemIDs,
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
) )
viewModelScope.launch { viewModelScope.launch {
repo.upsertBattery(battery) repo.upsertBattery(battery)
if (isNew && !loggedCreate) { if (isNew && !loggedCreate) {
loggedCreate = true loggedCreate = true
Analytics.log("Battery Created", mapOf("name" to s.name, "voltage" to s.nominalVoltage, "capacity" to s.capacityAmpHours, "chemistry" to s.chemistry.rawValue)) Analytics.log("Battery Created", mapOf("name" to s.name, "system" to systemName))
} }
} }
} }

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.chargers
import androidx.lifecycle.ViewModel 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.R
import app.voltplan.cable.analytics.Analytics import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.LocaleDefaults import app.voltplan.cable.data.LocaleDefaults
import app.voltplan.cable.data.model.PowerSourceType import app.voltplan.cable.data.model.PowerSourceType
@@ -17,7 +18,7 @@ import java.util.UUID
enum class PowerEntryMode { CURRENT, POWER } enum class PowerEntryMode { CURRENT, POWER }
data class ChargerState( data class ChargerState(
val name: String = "New Charger", val name: String = "",
val sourceType: PowerSourceType = PowerSourceType.SHORE, val sourceType: PowerSourceType = PowerSourceType.SHORE,
val inputVoltage: Double = 230.0, val inputVoltage: Double = 230.0,
val outputVoltage: Double = 14.2, val outputVoltage: Double = 14.2,
@@ -38,7 +39,7 @@ data class ChargerState(
} }
class ChargerEditorViewModel( class ChargerEditorViewModel(
app: CableApplication, private val app: CableApplication,
private val systemId: String, private val systemId: String,
chargerId: String?, chargerId: String?,
) : ViewModel() { ) : ViewModel() {
@@ -46,14 +47,27 @@ class ChargerEditorViewModel(
private var id: String = chargerId ?: UUID.randomUUID().toString() private var id: String = chargerId ?: UUID.randomUUID().toString()
private val isNew = chargerId == null private val isNew = chargerId == null
private var loggedCreate = false private var loggedCreate = false
private var systemName: String = ""
// Fields the editor doesn't touch but must survive a save round-trip.
private var remoteIconURLString: String? = null
private var affiliateURLString: String? = null
private var affiliateCountryCode: String? = null
private var bomCompletedItemIDs: List<String> = emptyList()
private val _state = MutableStateFlow(ChargerState(inputVoltage = LocaleDefaults.mainsVoltage)) private val _state = MutableStateFlow(ChargerState(inputVoltage = LocaleDefaults.mainsVoltage))
val state: StateFlow<ChargerState> = _state.asStateFlow() val state: StateFlow<ChargerState> = _state.asStateFlow()
init { init {
viewModelScope.launch { viewModelScope.launch {
val system = repo.getSystem(systemId)
systemName = system?.name ?: ""
if (chargerId != null) { if (chargerId != null) {
repo.getCharger(chargerId)?.let { c -> repo.getCharger(chargerId)?.let { c ->
remoteIconURLString = c.remoteIconURLString
affiliateURLString = c.affiliateURLString
affiliateCountryCode = c.affiliateCountryCode
bomCompletedItemIDs = c.bomCompletedItemIDs
val mode = if (c.maxPowerWatts > 0) PowerEntryMode.POWER else PowerEntryMode.CURRENT val mode = if (c.maxPowerWatts > 0) PowerEntryMode.POWER else PowerEntryMode.CURRENT
val current = if (c.maxPowerWatts > 0 && c.outputVoltage > 0) Fmt.roundToTenth(c.maxPowerWatts / c.outputVoltage) else c.maxCurrentAmps val current = if (c.maxPowerWatts > 0 && c.outputVoltage > 0) Fmt.roundToTenth(c.maxPowerWatts / c.outputVoltage) else c.maxCurrentAmps
_state.value = ChargerState( _state.value = ChargerState(
@@ -70,14 +84,22 @@ class ChargerEditorViewModel(
) )
} }
} else { } else {
val color = repo.getSystem(systemId)?.colorName ?: "orange" val color = system?.colorName ?: "orange"
val name = repo.uniqueComponentName(systemId, "New Charger") val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_charger_new))
_state.value = _state.value.copy(name = name, colorName = color) _state.value = _state.value.copy(name = name, colorName = color)
} }
Analytics.log("Charger Editor Opened", mapOf("source" to if (isNew) "create" else "edit")) Analytics.log("Charger Editor Opened", mapOf("source" to if (isNew) "create" else "edit", "system" to systemName))
} }
} }
override fun onCleared() {
// Mirrors iOS, which logs "Charger Updated" when the editor for an existing charger closes.
if (!isNew) {
Analytics.log("Charger Updated", mapOf("name" to _state.value.name, "system" to systemName))
}
super.onCleared()
}
private fun update(transform: (ChargerState) -> ChargerState) { private fun update(transform: (ChargerState) -> ChargerState) {
_state.value = transform(_state.value) _state.value = transform(_state.value)
persist() persist()
@@ -135,13 +157,17 @@ class ChargerEditorViewModel(
colorName = s.colorName, colorName = s.colorName,
systemId = systemId, systemId = systemId,
timestamp = System.currentTimeMillis(), timestamp = System.currentTimeMillis(),
remoteIconURLString = remoteIconURLString,
affiliateURLString = affiliateURLString,
affiliateCountryCode = affiliateCountryCode,
bomCompletedItemIDs = bomCompletedItemIDs,
powerSourceType = s.sourceType.rawValue, powerSourceType = s.sourceType.rawValue,
) )
viewModelScope.launch { viewModelScope.launch {
repo.upsertCharger(charger) repo.upsertCharger(charger)
if (isNew && !loggedCreate) { if (isNew && !loggedCreate) {
loggedCreate = true loggedCreate = true
Analytics.log("Charger Created", mapOf("name" to s.name, "output_voltage" to s.outputVoltage, "max_current" to s.maxCurrentAmps, "max_power" to s.maxPowerWatts)) Analytics.log("Charger Created", mapOf("name" to s.name, "system" to systemName))
} }
} }
} }

View File

@@ -37,6 +37,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import app.voltplan.cable.R
import app.voltplan.cable.ui.sfSymbol import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.theme.componentColor import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.ui.theme.curatedColorNames import app.voltplan.cable.ui.theme.curatedColorNames
@@ -77,7 +79,7 @@ fun AppearanceEditorSheet(
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
TextButton(onClick = { onSave(name, icon, color); onDismiss() }) { TextButton(onClick = { onSave(name, icon, color); onDismiss() }) {
Text("Save", fontWeight = FontWeight.SemiBold) Text(stringResource(R.string.action_save), fontWeight = FontWeight.SemiBold)
} }
} }
@@ -112,7 +114,7 @@ fun AppearanceEditorSheet(
extra?.invoke() extra?.invoke()
Text("Icon", style = MaterialTheme.typography.titleSmall) Text(stringResource(R.string.editor_icon), style = MaterialTheme.typography.titleSmall)
GridRows(items = icons, columns = 5) { symbol -> GridRows(items = icons, columns = 5) { symbol ->
val selected = symbol == icon val selected = symbol == icon
Box( Box(
@@ -133,7 +135,7 @@ fun AppearanceEditorSheet(
} }
} }
Text("Color", style = MaterialTheme.typography.titleSmall) Text(stringResource(R.string.editor_color), style = MaterialTheme.typography.titleSmall)
GridRows(items = curatedColorNames, columns = 6) { colorName -> GridRows(items = curatedColorNames, columns = 6) { colorName ->
val c = componentColor(colorName) val c = componentColor(colorName)
Box( Box(

View File

@@ -20,9 +20,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import app.voltplan.cable.util.Fmt import app.voltplan.cable.util.Fmt
import kotlin.math.abs import kotlin.math.abs
@@ -113,8 +115,8 @@ fun ValueEditDialog(
} }
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { Fmt.parseInput(text)?.let(onConfirm); onDismiss() }) { Text("Save") } TextButton(onClick = { Fmt.parseInput(text)?.let(onConfirm); onDismiss() }) { Text(stringResource(R.string.action_save)) }
}, },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
) )
} }

View File

@@ -67,13 +67,12 @@ fun ComponentLibraryScreen(
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Analytics.log( val props = mutableMapOf<String, Any>(
"Component Library Opened", "source" to if (targetSystemId != null) "system" else "systems-list",
mapOf( "type" to libraryType.typeValue,
"source" to if (targetSystemId != null) "system" else "systems-list",
"type" to libraryType.typeValue,
),
) )
vm.systemName(targetSystemId)?.let { props["system"] = it }
Analytics.log("Component Library Opened", props)
} }
Scaffold( Scaffold(

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.loads
import androidx.lifecycle.ViewModel 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.R
import app.voltplan.cable.analytics.Analytics import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.calc.ElectricalCalculations import app.voltplan.cable.calc.ElectricalCalculations
import app.voltplan.cable.data.UnitSystem import app.voltplan.cable.data.UnitSystem
@@ -15,7 +16,7 @@ import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
data class CalcState( data class CalcState(
val loadName: String = "My Load", val loadName: String = "",
val voltage: Double = 12.0, val voltage: Double = 12.0,
val current: Double = 5.0, val current: Double = 5.0,
val power: Double = 60.0, val power: Double = 60.0,
@@ -36,7 +37,7 @@ data class CalcState(
} }
class CalculatorViewModel( class CalculatorViewModel(
app: CableApplication, private val app: CableApplication,
private val systemId: String, private val systemId: String,
loadId: String?, loadId: String?,
) : ViewModel() { ) : ViewModel() {
@@ -73,9 +74,13 @@ class CalculatorViewModel(
} }
} }
} else { } else {
// Mirror SystemComponentsPersistence default load values. // Mirror SystemComponentsPersistence default load values, including the
_state.value = CalcState() // localized unique default name ("New Load", "New Load 2", ...).
persist(created = true) viewModelScope.launch {
val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_load_new))
_state.value = CalcState(loadName = name)
persist(created = true)
}
} }
} }

View File

@@ -138,7 +138,10 @@ fun SystemDetailScreen(
system.name, system.name,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp).clickable { showSystemEditor = true }, modifier = Modifier.padding(start = 8.dp).clickable {
vm.logSystemEditorOpened("toolbar")
showSystemEditor = true
},
) )
} }
} }

View File

@@ -43,11 +43,14 @@ class SystemDetailViewModel(
Analytics.log("Tab Changed", mapOf("tab" to tab, "system" to (state.value.system?.name ?: ""))) Analytics.log("Tab Changed", mapOf("tab" to tab, "system" to (state.value.system?.name ?: "")))
} }
fun logSystemEditorOpened(source: String) {
Analytics.log("System Editor Opened", mapOf("source" to source, "system" to (state.value.system?.name ?: "")))
}
fun saveSystem(name: String, location: String, iconName: String, colorName: String) { fun saveSystem(name: String, location: String, iconName: String, colorName: String) {
val current = state.value.system ?: return val current = state.value.system ?: return
viewModelScope.launch { viewModelScope.launch {
repo.upsertSystem(current.copy(name = name, location = location, iconName = iconName, colorName = colorName)) repo.upsertSystem(current.copy(name = name, location = location, iconName = iconName, colorName = colorName))
Analytics.log("System Updated", mapOf("name" to name))
} }
} }

View File

@@ -84,7 +84,7 @@ fun SystemsScreen(
SystemsOnboarding( SystemsOnboarding(
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
onCreate = { name -> onCreate = { name ->
vm.createSystem(name, source = "onboarding", randomColor = true, onCreated = onOpenSystem) vm.createSystem(name, source = "named", randomColor = true, onCreated = onOpenSystem)
}, },
) )
} else { } else {
@@ -167,6 +167,11 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String)
val defaultName = stringResource(R.string.default_system_name) val defaultName = stringResource(R.string.default_system_name)
val effective = remember(name) { name } val effective = remember(name) { name }
// Mirrors iOS, which logs "Launched" when the onboarding view appears.
androidx.compose.runtime.LaunchedEffect(Unit) {
app.voltplan.cable.analytics.Analytics.log("Launched")
}
Column( Column(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.ElectricalSystem import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.SavedLoad import app.voltplan.cable.data.model.SavedLoad
@@ -37,7 +38,8 @@ class SystemsViewModel(app: Application) : AndroidViewModel(app) {
/** Creates a system and returns its id via the callback. [source] is for analytics. */ /** Creates a system and returns its id via the callback. [source] is for analytics. */
fun createSystem(preferredName: String?, source: String, randomColor: Boolean, onCreated: (String) -> Unit) { fun createSystem(preferredName: String?, source: String, randomColor: Boolean, onCreated: (String) -> Unit) {
viewModelScope.launch { viewModelScope.launch {
val base = preferredName?.trim().takeUnless { it.isNullOrEmpty() } ?: "New System" val base = preferredName?.trim().takeUnless { it.isNullOrEmpty() }
?: getApplication<Application>().getString(R.string.default_system_new)
val name = repo.uniqueSystemName(base) val name = repo.uniqueSystemName(base)
val color = if (randomColor) SystemIconMapper.colorOptions.random() else "blue" val color = if (randomColor) SystemIconMapper.colorOptions.random() else "blue"
val system = ElectricalSystem( val system = ElectricalSystem(

View File

@@ -263,4 +263,16 @@
<!-- Misc --> <!-- Misc -->
<string name="component_fallback_name">Komponente</string> <string name="component_fallback_name">Komponente</string>
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
<string name="action_cancel">Abbrechen</string>
<string name="editor_icon">Symbol</string>
<string name="editor_color">Farbe</string>
<string name="overview_pdf_load_length">Kabellänge</string>
<string name="overview_pdf_load_ploss">Leistungsverlust</string>
<string name="overview_pdf_battery_usableenergy">Nutzbare Energie</string>
<string name="default_load_new">Neuer Verbraucher</string>
<string name="default_battery_new">Neue Batterie</string>
<string name="default_charger_new">Neues Ladegerät</string>
<string name="default_system_new">Neues System</string>
</resources> </resources>

View File

@@ -263,4 +263,16 @@
<!-- Misc --> <!-- Misc -->
<string name="component_fallback_name">Componente</string> <string name="component_fallback_name">Componente</string>
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
<string name="action_cancel">Cancelar</string>
<string name="editor_icon">Icono</string>
<string name="editor_color">Color</string>
<string name="overview_pdf_load_length">Longitud del cable</string>
<string name="overview_pdf_load_ploss">Pérdida de potencia</string>
<string name="overview_pdf_battery_usableenergy">Energía utilizable</string>
<string name="default_load_new">Carga nueva</string>
<string name="default_battery_new">Nueva batería</string>
<string name="default_charger_new">Nuevo cargador</string>
<string name="default_system_new">Sistema nuevo</string>
</resources> </resources>

View File

@@ -263,4 +263,16 @@
<!-- Misc --> <!-- Misc -->
<string name="component_fallback_name">Composant</string> <string name="component_fallback_name">Composant</string>
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
<string name="action_cancel">Annuler</string>
<string name="editor_icon">Icône</string>
<string name="editor_color">Couleur</string>
<string name="overview_pdf_load_length">Longueur du câble</string>
<string name="overview_pdf_load_ploss">Perte de puissance</string>
<string name="overview_pdf_battery_usableenergy">Énergie utilisable</string>
<string name="default_load_new">Nouvelle charge</string>
<string name="default_battery_new">Nouvelle batterie</string>
<string name="default_charger_new">Nouveau chargeur</string>
<string name="default_system_new">Nouveau système</string>
</resources> </resources>

View File

@@ -263,4 +263,16 @@
<!-- Misc --> <!-- Misc -->
<string name="component_fallback_name">Component</string> <string name="component_fallback_name">Component</string>
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
<string name="action_cancel">Annuleren</string>
<string name="editor_icon">Pictogram</string>
<string name="editor_color">Kleur</string>
<string name="overview_pdf_load_length">Kabellengte</string>
<string name="overview_pdf_load_ploss">Vermogensverlies</string>
<string name="overview_pdf_battery_usableenergy">Bruikbare energie</string>
<string name="default_load_new">Nieuwe last</string>
<string name="default_battery_new">Nieuwe batterij</string>
<string name="default_charger_new">Nieuwe lader</string>
<string name="default_system_new">Nieuw systeem</string>
</resources> </resources>

View File

@@ -263,4 +263,16 @@
<!-- Misc --> <!-- Misc -->
<string name="component_fallback_name">Component</string> <string name="component_fallback_name">Component</string>
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
<string name="action_cancel">Cancel</string>
<string name="editor_icon">Icon</string>
<string name="editor_color">Color</string>
<string name="overview_pdf_load_length">Cable Length</string>
<string name="overview_pdf_load_ploss">Power Loss</string>
<string name="overview_pdf_battery_usableenergy">Usable Energy</string>
<string name="default_load_new">New Load</string>
<string name="default_battery_new">New Battery</string>
<string name="default_charger_new">New Charger</string>
<string name="default_system_new">New System</string>
</resources> </resources>