Add native Kotlin/Compose Android port

Full feature-parity port of the iOS app under ./android (package
app.voltplan.cable): Systems, bottom-nav system detail (Overview,
Components, Batteries, Chargers), calculator/loads, battery & charger
editors, overview with runtime/charge goals, Bill of Materials with PDF
export, VoltPlan PocketBase component library, and Aptabase analytics.
Room persistence, 5-language localization. Verified to build (assembleDebug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 10:36:08 +02:00
parent ea3b60d75c
commit 61f340a870
81 changed files with 7723 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
}
android {
namespace = "app.voltplan.cable"
compileSdk = 35
defaultConfig {
applicationId = "app.voltplan.cable"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
buildConfigField("String", "APTABASE_HOST", "\"https://apta.yuzuhub.com\"")
vectorDrawables { useSupportLibrary = true }
resourceConfigurations += listOf("en", "de", "es", "fr", "nl")
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.navigation.compose)
debugImplementation(libs.androidx.ui.tooling)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences)
implementation(libs.retrofit)
implementation(libs.retrofit.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.coil.compose)
}

12
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,12 @@
# Keep kotlinx.serialization metadata
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.**
-keepclassmembers class app.voltplan.cable.** {
*** Companion;
}
-keepclasseswithmembers class app.voltplan.cable.** {
kotlinx.serialization.KSerializer serializer(...);
}
# Retrofit
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".CableApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Cable"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Cable">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider for sharing exported PDFs -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,33 @@
package app.voltplan.cable
import android.app.Application
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.CableRepository
import app.voltplan.cable.data.UnitSystemSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Owns the singletons (repository, settings, analytics) shared across the app. */
class CableApplication : Application() {
lateinit var repository: CableRepository
private set
lateinit var settings: UnitSystemSettings
private set
override fun onCreate() {
super.onCreate()
Analytics.init(this)
repository = CableRepository(this)
settings = UnitSystemSettings(this)
// Mirrors AppDelegate.application(_:didFinishLaunchingWithOptions:).
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
if (settings.consumeFirstLaunch()) {
Analytics.log("First Launch")
}
Analytics.log("App Launched")
}
}
}

View File

@@ -0,0 +1,25 @@
package app.voltplan.cable
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.CompositionLocalProvider
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.navigation.CableNavHost
import app.voltplan.cable.ui.theme.CableTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as CableApplication
setContent {
CableTheme {
CompositionLocalProvider(LocalUnitSettings provides app.settings) {
CableNavHost()
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
package app.voltplan.cable.affiliate
import android.net.Uri
/** Builds locale-aware Amazon search URLs with affiliate tags. Direct port of `AmazonAffiliate`. */
object AmazonAffiliate {
private const val FALLBACK_DOMAIN = "www.amazon.com"
private const val FALLBACK_TAG = "voltplan-20"
private val domainsByCountry = mapOf(
"US" to "www.amazon.com", "DE" to "www.amazon.de", "FR" to "www.amazon.fr",
"ES" to "www.amazon.es", "IT" to "www.amazon.it", "GB" to "www.amazon.co.uk",
"CA" to "www.amazon.ca", "JP" to "www.amazon.co.jp", "AU" to "www.amazon.com.au",
"NL" to "www.amazon.nl", "SE" to "www.amazon.se", "PL" to "www.amazon.pl",
"MX" to "www.amazon.com.mx", "BR" to "www.amazon.com.br", "IN" to "www.amazon.in",
)
private val tagsByCountry = mapOf(
"US" to "voltplan-20", "DE" to "voltplan-21", "AU" to "voltplan-22",
"GB" to "voltplan00-21", "FR" to "voltplan0f-21", "CA" to "voltplan01-20",
)
private val aliases = mapOf("UK" to "GB")
private fun normalize(countryCode: String?): String? {
val upper = countryCode?.uppercase()?.trim()
if (upper.isNullOrEmpty()) return null
return aliases[upper] ?: upper
}
private fun domain(countryCode: String?): String {
val code = normalize(countryCode) ?: return FALLBACK_DOMAIN
return domainsByCountry[code] ?: FALLBACK_DOMAIN
}
private fun tag(countryCode: String?): String {
val code = normalize(countryCode) ?: return FALLBACK_TAG
return tagsByCountry[code] ?: FALLBACK_TAG
}
fun searchUrl(query: String, countryCode: String?): String? {
if (query.isBlank()) return null
val builder = Uri.Builder()
.scheme("https")
.authority(domain(countryCode))
.path("/s")
.appendQueryParameter("k", query)
tag(countryCode).takeIf { it.isNotEmpty() }?.let { builder.appendQueryParameter("tag", it) }
return builder.build().toString()
}
}

View File

@@ -0,0 +1,156 @@
package app.voltplan.cable.analytics
import android.content.Context
import android.os.Build
import android.util.Log
import app.voltplan.cable.BuildConfig
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Aptabase analytics, talking to the same instance the iOS app uses
* (app key A-SH-4260269603, host https://apta.yuzuhub.com).
*
* Implements the Aptabase ingestion protocol directly (`POST /api/v0/event`) so behaviour and
* payload shape match the official SDK without depending on an Android artifact. The public
* [log] entry point mirrors `AnalyticsTracker.log(_:properties:)`.
*/
object Analytics {
private const val TAG = "Analytics"
private const val SDK_VERSION = "cable-android@1.0.0"
private const val SESSION_TIMEOUT_MS = 60 * 60 * 1000L // 1 hour, matching Aptabase SDKs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { encodeDefaults = true }
private val client = OkHttpClient.Builder()
.callTimeout(15, TimeUnit.SECONDS)
.build()
private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
private var appKey: String = BuildConfig.APTABASE_APP_KEY
private var eventsUrl: String = BuildConfig.APTABASE_HOST.trimEnd('/') + "/api/v0/event"
private var enabled: Boolean = true
@Volatile private var sessionId: String = newSessionId()
@Volatile private var lastTouch: Long = 0L
private var osVersion: String = Build.VERSION.RELEASE ?: "unknown"
private var localeTag: String = Locale.getDefault().toLanguageTag()
private var appVersion: String = "1.0"
private var appBuild: String = "1"
fun init(context: Context) {
appKey = BuildConfig.APTABASE_APP_KEY
eventsUrl = BuildConfig.APTABASE_HOST.trimEnd('/') + "/api/v0/event"
enabled = appKey.isNotBlank()
localeTag = Locale.getDefault().toLanguageTag()
osVersion = Build.VERSION.RELEASE ?: "unknown"
runCatching {
val pkg = context.packageManager.getPackageInfo(context.packageName, 0)
appVersion = pkg.versionName ?: "1.0"
appBuild = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
pkg.longVersionCode.toString()
} else {
@Suppress("DEPRECATION") pkg.versionCode.toString()
}
}
}
/** Tracks an event. [properties] values are coerced to String/Number/Boolean like the iOS tracker. */
fun log(event: String, properties: Map<String, Any?> = emptyMap()) {
if (BuildConfig.DEBUG) {
if (properties.isEmpty()) {
Log.d(TAG, "Analytics: $event")
} else {
val formatted = properties.entries.sortedBy { it.key }
.joinToString(", ") { "${it.key}=${it.value}" }
Log.d(TAG, "Analytics: $event { $formatted }")
}
}
val props = buildJsonObject {
for ((key, value) in properties) {
when (value) {
null -> {}
is String -> put(key, value)
is Boolean -> put(key, value)
is Int -> put(key, value)
is Long -> put(key, value)
is Float -> put(key, value)
is Double -> put(key, value)
else -> put(key, value.toString())
}
}
}
if (enabled) send(event, props)
}
private fun send(event: String, props: JsonObject) {
val body = buildJsonObject {
put("timestamp", isoFormatter.format(Date()))
put("sessionId", currentSessionId())
put("eventName", event)
put("systemProps", buildJsonObject {
put("isDebug", BuildConfig.DEBUG)
put("osName", "Android")
put("osVersion", osVersion)
put("locale", localeTag)
put("appVersion", appVersion)
put("appBuildNumber", appBuild)
put("sdkVersion", SDK_VERSION)
})
put("props", props)
}
val request = Request.Builder()
.url(eventsUrl)
.addHeader("App-Key", appKey)
.addHeader("Content-Type", "application/json")
.post(json.encodeToString(JsonObject.serializer(), body).toRequestBody(JSON_MEDIA))
.build()
scope.launch {
runCatching {
client.newCall(request).execute().use { response ->
if (!response.isSuccessful && BuildConfig.DEBUG) {
Log.w(TAG, "Aptabase responded ${response.code} for $event")
}
}
}.onFailure { if (BuildConfig.DEBUG) Log.w(TAG, "Aptabase send failed: ${it.message}") }
}
}
@Synchronized
private fun currentSessionId(): String {
val now = System.currentTimeMillis()
if (now - lastTouch > SESSION_TIMEOUT_MS) {
sessionId = newSessionId()
}
lastTouch = now
return sessionId
}
private fun newSessionId(): String =
(System.currentTimeMillis() / 1000).toString() + UUID.randomUUID().toString().replace("-", "").take(20)
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
}

View File

@@ -0,0 +1,121 @@
package app.voltplan.cable.calc
import app.voltplan.cable.data.UnitSystem
import kotlin.math.ceil
/**
* Pure wire-sizing math. Direct port of the iOS `ElectricalCalculations` struct.
* Lengths are always in metres; [UnitSystem] only controls the output format (mm² vs AWG).
*/
object ElectricalCalculations {
private const val MAX_VOLTAGE_DROP_FRACTION = 0.05
private const val COPPER_RESISTIVITY = 0.017 // Ω·mm²/m
const val FEET_PER_METER = 3.28084
private val standardMetricCrossSections = listOf(
0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0,
150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0,
)
// Convention: 1/0 = -1, 2/0 = -2, 3/0 = -3, 4/0 = -4
private val standardAWG = listOf(20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, -1, -2, -3, -4)
private val awgCrossSections = listOf(
0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0,
)
private val standardFuses = listOf(
1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0,
60.0, 70.0, 80.0, 100.0, 125.0, 150.0, 175.0, 200.0, 225.0, 250.0,
300.0, 350.0, 400.0, 450.0, 500.0, 600.0, 700.0, 800.0,
)
fun recommendedCrossSection(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
): Double {
val maxVoltageDrop = voltage * MAX_VOLTAGE_DROP_FRACTION
val minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
(2 * current * length * COPPER_RESISTIVITY) / maxVoltageDrop
}
return if (unitSystem == UnitSystem.IMPERIAL) {
for (index in awgCrossSections.indices) {
if (awgCrossSections[index] >= minimumCrossSection) {
return standardAWG[index].toDouble()
}
}
(standardAWG.lastOrNull() ?: 0).toDouble()
} else {
val floor = standardMetricCrossSections.first()
standardMetricCrossSections.firstOrNull { it >= maxOf(floor, minimumCrossSection) }
?: standardMetricCrossSections.last()
}
}
fun voltageDrop(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = null,
): Double {
val selected = crossSection
?: recommendedCrossSection(length, current, voltage, unitSystem)
val crossSectionMM2 = if (unitSystem == UnitSystem.METRIC) {
selected
} else {
crossSectionFromAWG(selected)
}
if (crossSectionMM2 <= 0) return 0.0
return (2 * current * length * COPPER_RESISTIVITY) / crossSectionMM2
}
fun voltageDropPercentage(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = null,
): Double {
if (voltage == 0.0) return 0.0
return voltageDrop(length, current, voltage, unitSystem, crossSection) / voltage * 100
}
fun powerLoss(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = null,
): Double {
return current * voltageDrop(length, current, voltage, unitSystem, crossSection)
}
fun recommendedFuse(forCurrent: Double): Double {
val target = ceil(forCurrent * 1.25)
return standardFuses.firstOrNull { it >= target } ?: standardFuses.last()
}
private inline fun guardAgainstZero(divisor: Double, calculation: () -> Double): Double {
if (divisor <= 0) return 0.0
return calculation()
}
private fun crossSectionFromAWG(awg: Double): Double {
val index = standardAWG.indexOf(awg.toInt())
return if (index in awgCrossSections.indices) awgCrossSections[index] else 0.75
}
/** Formats an AWG value: positive as-is, negative as "X/0" notation. */
fun formatAWG(awg: Double): String = when (awg.toInt()) {
-1 -> "1/0"
-2 -> "2/0"
-3 -> "3/0"
-4 -> "4/0"
else -> "${awg.toInt()}"
}
}

View File

@@ -0,0 +1,116 @@
package app.voltplan.cable.calc
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.effectivePowerWatts
import app.voltplan.cable.data.model.energyWattHours
import app.voltplan.cable.data.model.usableCapacityAmpHours
import app.voltplan.cable.data.model.usableEnergyWattHours
import kotlin.math.abs
import kotlin.math.roundToInt
/** Battery-bank mismatch warning. Mirrors the iOS overview/battery-bank logic. */
sealed interface BankWarning {
data class Voltage(val count: Int, val baseline: Double) : BankWarning
data class Capacity(val count: Int, val baseline: Double) : BankWarning
}
/** Aggregated system metrics. All formulas ported from `SystemOverviewView`. */
class SystemMetrics(
val loads: List<SavedLoad>,
val batteries: List<SavedBattery>,
val chargers: List<SavedCharger>,
) {
val totalPower: Double = loads.sumOf { maxOf(it.power, 0.0) }
val totalCurrent: Double = loads.sumOf { maxOf(it.current, 0.0) }
val totalCapacity: Double = batteries.sumOf { it.capacityAmpHours }
val totalUsableCapacity: Double = batteries.sumOf { it.usableCapacityAmpHours }
val totalEnergy: Double = batteries.sumOf { it.energyWattHours }
val totalUsableEnergy: Double = batteries.sumOf { it.usableEnergyWattHours }
val totalChargerCurrent: Double = chargers.sumOf { maxOf(it.maxCurrentAmps, 0.0) }
val totalChargerPower: Double = chargers.sumOf { maxOf(it.effectivePowerWatts, 0.0) }
val representativeChargerOutput: Double? = chargers.map { it.outputVoltage }.filter { it > 0 }
.takeIf { it.isNotEmpty() }?.average()
/** Average continuous draw, weighting each load by duty cycle and daily on-time. */
val totalAverageLoadPower: Double = loads.sumOf { load ->
val power = maxOf(load.power, 0.0)
if (power <= 0) 0.0 else {
val duty = load.dutyCyclePercent.coerceIn(0.0, 100.0) / 100.0
val usage = load.dailyUsageHours.coerceIn(0.0, 24.0) / 24.0
power * duty * usage
}
}
val estimatedRuntimeHours: Double? =
ratio(totalUsableEnergy, totalAverageLoadPower)
val estimatedChargeHours: Double? =
ratio(totalUsableCapacity, totalChargerCurrent)
private fun ratio(numerator: Double, denominator: Double): Double? {
if (numerator.isFinite() && denominator.isFinite() && numerator > 0 && denominator > 0) {
return numerator / denominator
}
return null
}
val bankWarning: BankWarning? by lazy {
if (batteries.size < 2) return@lazy null
// Voltage mismatch (binned to 0.1V).
val vBaseline = dominant(batteries.map { it.nominalVoltage }, 0.1)
val vCount = batteries.count { abs(it.nominalVoltage - vBaseline) > 0.05 }
if (vCount > 0) return@lazy BankWarning.Voltage(vCount, vBaseline)
// Capacity mismatch (binned to 1.0 Ah).
val cBaseline = dominant(batteries.map { it.capacityAmpHours }, 1.0)
val cCount = batteries.count { abs(it.capacityAmpHours - cBaseline) > 0.5 }
if (cCount > 0) return@lazy BankWarning.Capacity(cCount, cBaseline)
null
}
private fun dominant(values: List<Double>, bin: Double): Double {
val counts = HashMap<Int, Int>()
for (v in values) {
val key = (v / bin).roundToInt()
counts[key] = (counts[key] ?: 0) + 1
}
val bestKey = counts.maxByOrNull { it.value }?.key ?: 0
return bestKey * bin
}
// --- Bill of Materials completion ---
private val settledLoads = loads.filter { it.length > 0 && it.crossSection > 0 }
val bomItemsCount: Int = settledLoads.size * 5 + batteries.size * 1 + chargers.size * 1
val completedBomItemCount: Int = run {
val loadDone = settledLoads.sumOf { minOf(it.bomCompletedItemIDs.toSet().size, 5) }
val battDone = batteries.sumOf { minOf(it.bomCompletedItemIDs.toSet().size, 1) }
val chgDone = chargers.sumOf { minOf(it.bomCompletedItemIDs.toSet().size, 1) }
loadDone + battDone + chgDone
}
val bomCompletionFraction: Double? =
if (bomItemsCount > 0) completedBomItemCount.toDouble() / bomItemsCount else null
val loadsMissingDetails: Int = loads.count { it.length <= 0 || it.crossSection <= 0 }
}
/** Formats a duration in hours as an abbreviated "Xd Yh" / "Yh Zm" string (max 2 units). */
fun formatDurationHours(hours: Double): String {
if (!hours.isFinite() || hours <= 0) return ""
val totalMinutes = (hours * 60).roundToInt()
val days = totalMinutes / (24 * 60)
val remAfterDays = totalMinutes % (24 * 60)
val hrs = remAfterDays / 60
val mins = remAfterDays % 60
val parts = buildList {
if (days > 0) add("${days}d")
if (hrs > 0) add("${hrs}h")
if (mins > 0 && days == 0) add("${mins}m")
}
return parts.take(2).joinToString(" ").ifEmpty { "0m" }
}

View File

@@ -0,0 +1,81 @@
package app.voltplan.cable.data
import android.content.Context
import app.voltplan.cable.data.db.CableDatabase
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.SavedLoad
import kotlinx.coroutines.flow.Flow
/** Single point of access to the persistence layer. */
class CableRepository(context: Context) {
private val db = CableDatabase.get(context)
private val systemDao = db.systemDao()
private val loadDao = db.loadDao()
private val batteryDao = db.batteryDao()
private val chargerDao = db.chargerDao()
// Systems
fun observeSystems(): Flow<List<ElectricalSystem>> = systemDao.observeAll()
fun observeSystem(id: String): Flow<ElectricalSystem?> = systemDao.observe(id)
suspend fun getSystem(id: String) = systemDao.get(id)
suspend fun systemCount() = systemDao.count()
suspend fun upsertSystem(system: ElectricalSystem) = systemDao.upsert(system)
suspend fun deleteSystem(system: ElectricalSystem) {
// Cascade delete children, mirroring the iOS deleteSystems behaviour.
loadDao.deleteForSystem(system.id)
batteryDao.deleteForSystem(system.id)
chargerDao.deleteForSystem(system.id)
systemDao.delete(system)
}
// Loads
fun observeAllLoads(): Flow<List<SavedLoad>> = loadDao.observeAll()
fun observeLoads(systemId: String): Flow<List<SavedLoad>> = loadDao.observeForSystem(systemId)
suspend fun getLoad(id: String) = loadDao.get(id)
suspend fun upsertLoad(load: SavedLoad) = loadDao.upsert(load)
suspend fun deleteLoad(load: SavedLoad) = loadDao.delete(load)
// Batteries
fun observeBatteries(systemId: String): Flow<List<SavedBattery>> = batteryDao.observeForSystem(systemId)
suspend fun getBattery(id: String) = batteryDao.get(id)
suspend fun upsertBattery(battery: SavedBattery) = batteryDao.upsert(battery)
suspend fun deleteBattery(battery: SavedBattery) = batteryDao.delete(battery)
// Chargers
fun observeChargers(systemId: String): Flow<List<SavedCharger>> = chargerDao.observeForSystem(systemId)
suspend fun getCharger(id: String) = chargerDao.get(id)
suspend fun upsertCharger(charger: SavedCharger) = chargerDao.upsert(charger)
suspend fun deleteCharger(charger: SavedCharger) = chargerDao.delete(charger)
/** Picks a unique component name within a system. Mirrors `SystemComponentsPersistence.uniqueName`. */
suspend fun uniqueComponentName(systemId: String, baseName: String): String {
val existing = buildSet {
loadDao.listForSystem(systemId).forEach { add(it.name) }
batteryDao.listForSystem(systemId).forEach { add(it.name) }
chargerDao.listForSystem(systemId).forEach { add(it.name) }
}
if (baseName !in existing) return baseName
var counter = 2
var candidate = "$baseName $counter"
while (candidate in existing) {
counter++
candidate = "$baseName $counter"
}
return candidate
}
/** Picks a unique system name. Mirrors the iOS `makeSystem` naming loop. */
suspend fun uniqueSystemName(baseName: String): String {
val names = systemDao.allNames().toSet()
if (baseName !in names) return baseName
var counter = 2
var candidate = "$baseName $counter"
while (candidate in names) {
counter++
candidate = "$baseName $counter"
}
return candidate
}
}

View File

@@ -0,0 +1,34 @@
package app.voltplan.cable.data
import java.util.Locale
/** Metric (mm², m) or imperial (AWG, ft). Mirrors the iOS `UnitSystem` enum. */
enum class UnitSystem(val rawValue: String) {
METRIC("metric"),
IMPERIAL("imperial");
val wireAreaUnit: String get() = if (this == METRIC) "mm²" else "AWG"
val lengthUnit: String get() = if (this == METRIC) "m" else "ft"
companion object {
fun fromRaw(raw: String?): UnitSystem? = entries.firstOrNull { it.rawValue == raw }
/** Default for the device locale — US measurement system implies imperial. */
fun deviceDefault(): UnitSystem {
val country = Locale.getDefault().country.uppercase()
// Countries that customarily use US/imperial measurements.
return if (country in setOf("US", "LR", "MM")) IMPERIAL else METRIC
}
}
}
/** Locale-aware electrical defaults. Mirrors the iOS `LocaleDefaults`. */
object LocaleDefaults {
private val lowVoltageRegions = setOf("US", "CA", "MX", "JP", "TW", "CO", "VE", "BR")
val mainsVoltage: Double
get() {
val region = Locale.getDefault().country.uppercase()
return if (region in lowVoltageRegions) 120.0 else 230.0
}
}

View File

@@ -0,0 +1,55 @@
package app.voltplan.cable.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import app.voltplan.cable.analytics.Analytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.flow.first
private val Context.dataStore by preferencesDataStore(name = "cable_settings")
private val UNIT_SYSTEM_KEY = stringPreferencesKey("unitSystem")
private val LAUNCHED_BEFORE_KEY = stringPreferencesKey("hasLaunchedBefore")
/**
* App-wide unit preference, persisted via DataStore. Mirrors the iOS `UnitSystemSettings`
* ObservableObject, including the "Unit System Changed" analytics event.
*/
class UnitSystemSettings(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _unitSystem = MutableStateFlow(
runBlocking {
val saved = context.dataStore.data.map { it[UNIT_SYSTEM_KEY] }.first()
UnitSystem.fromRaw(saved) ?: UnitSystem.deviceDefault()
},
)
val unitSystem: StateFlow<UnitSystem> = _unitSystem
fun setUnitSystem(system: UnitSystem) {
if (_unitSystem.value == system) return
_unitSystem.value = system
scope.launch {
context.dataStore.edit { it[UNIT_SYSTEM_KEY] = system.rawValue }
}
Analytics.log("Unit System Changed", mapOf("system" to system.rawValue))
}
/** Returns true exactly once — on the first launch ever — mirroring the iOS UserDefaults flag. */
suspend fun consumeFirstLaunch(): Boolean {
val launched = context.dataStore.data.map { it[LAUNCHED_BEFORE_KEY] }.first()
if (launched == null) {
context.dataStore.edit { it[LAUNCHED_BEFORE_KEY] = "true" }
return true
}
return false
}
}

View File

@@ -0,0 +1,42 @@
package app.voltplan.cable.data.db
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.SavedLoad
@Database(
entities = [
ElectricalSystem::class,
SavedLoad::class,
SavedBattery::class,
SavedCharger::class,
],
version = 1,
exportSchema = false,
)
@TypeConverters(Converters::class)
abstract class CableDatabase : RoomDatabase() {
abstract fun systemDao(): SystemDao
abstract fun loadDao(): LoadDao
abstract fun batteryDao(): BatteryDao
abstract fun chargerDao(): ChargerDao
companion object {
@Volatile
private var instance: CableDatabase? = null
fun get(context: Context): CableDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
CableDatabase::class.java,
"cable.db",
).fallbackToDestructiveMigration().build().also { instance = it }
}
}
}

View File

@@ -0,0 +1,14 @@
package app.voltplan.cable.data.db
import androidx.room.TypeConverter
/** Stores `List<String>` (BOM completed item IDs) as a newline-delimited blob. */
class Converters {
@TypeConverter
fun fromStringList(value: List<String>?): String =
value?.joinToString("\n") ?: ""
@TypeConverter
fun toStringList(value: String?): List<String> =
if (value.isNullOrEmpty()) emptyList() else value.split("\n").filter { it.isNotEmpty() }
}

View File

@@ -0,0 +1,119 @@
package app.voltplan.cable.data.db
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import androidx.room.Upsert
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.SavedLoad
import kotlinx.coroutines.flow.Flow
@Dao
interface SystemDao {
@Query("SELECT * FROM systems ORDER BY timestamp DESC")
fun observeAll(): Flow<List<ElectricalSystem>>
@Query("SELECT * FROM systems WHERE id = :id")
fun observe(id: String): Flow<ElectricalSystem?>
@Query("SELECT * FROM systems WHERE id = :id")
suspend fun get(id: String): ElectricalSystem?
@Query("SELECT COUNT(*) FROM systems")
suspend fun count(): Int
@Query("SELECT name FROM systems")
suspend fun allNames(): List<String>
@Upsert
suspend fun upsert(system: ElectricalSystem)
@Delete
suspend fun delete(system: ElectricalSystem)
@Query("DELETE FROM systems WHERE id = :id")
suspend fun deleteById(id: String)
}
@Dao
interface LoadDao {
@Query("SELECT * FROM loads ORDER BY timestamp DESC")
fun observeAll(): Flow<List<SavedLoad>>
@Query("SELECT * FROM loads WHERE systemId = :systemId ORDER BY timestamp DESC")
fun observeForSystem(systemId: String): Flow<List<SavedLoad>>
@Query("SELECT * FROM loads WHERE systemId = :systemId")
suspend fun listForSystem(systemId: String): List<SavedLoad>
@Query("SELECT * FROM loads WHERE id = :id")
suspend fun get(id: String): SavedLoad?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(load: SavedLoad)
@Update
suspend fun update(load: SavedLoad)
@Upsert
suspend fun upsert(load: SavedLoad)
@Delete
suspend fun delete(load: SavedLoad)
@Query("DELETE FROM loads WHERE systemId = :systemId")
suspend fun deleteForSystem(systemId: String)
}
@Dao
interface BatteryDao {
@Query("SELECT * FROM batteries ORDER BY timestamp DESC")
fun observeAll(): Flow<List<SavedBattery>>
@Query("SELECT * FROM batteries WHERE systemId = :systemId ORDER BY timestamp DESC")
fun observeForSystem(systemId: String): Flow<List<SavedBattery>>
@Query("SELECT * FROM batteries WHERE systemId = :systemId")
suspend fun listForSystem(systemId: String): List<SavedBattery>
@Query("SELECT * FROM batteries WHERE id = :id")
suspend fun get(id: String): SavedBattery?
@Upsert
suspend fun upsert(battery: SavedBattery)
@Delete
suspend fun delete(battery: SavedBattery)
@Query("DELETE FROM batteries WHERE systemId = :systemId")
suspend fun deleteForSystem(systemId: String)
}
@Dao
interface ChargerDao {
@Query("SELECT * FROM chargers ORDER BY timestamp DESC")
fun observeAll(): Flow<List<SavedCharger>>
@Query("SELECT * FROM chargers WHERE systemId = :systemId ORDER BY timestamp DESC")
fun observeForSystem(systemId: String): Flow<List<SavedCharger>>
@Query("SELECT * FROM chargers WHERE systemId = :systemId")
suspend fun listForSystem(systemId: String): List<SavedCharger>
@Query("SELECT * FROM chargers WHERE id = :id")
suspend fun get(id: String): SavedCharger?
@Upsert
suspend fun upsert(charger: SavedCharger)
@Delete
suspend fun delete(charger: SavedCharger)
@Query("DELETE FROM chargers WHERE systemId = :systemId")
suspend fun deleteForSystem(systemId: String)
}

View File

@@ -0,0 +1,107 @@
package app.voltplan.cable.data.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
/**
* Room entities mirroring the iOS SwiftData models. Parent/child links use a nullable
* [systemId] string instead of an object relationship; all values are stored in metric.
*/
@Entity(tableName = "systems")
data class ElectricalSystem(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String = "",
val location: String = "",
val timestamp: Long = System.currentTimeMillis(),
val iconName: String = "building.2",
val colorName: String = "blue",
val targetRuntimeHours: Double? = null,
val targetChargeTimeHours: Double? = null,
)
@Entity(
tableName = "loads",
indices = [Index("systemId")],
)
data class SavedLoad(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String = "",
val voltage: Double = 0.0,
val current: Double = 0.0,
val power: Double = 0.0,
val length: Double = 0.0,
val crossSection: Double = 0.0,
val timestamp: Long = System.currentTimeMillis(),
val iconName: String = "lightbulb",
val colorName: String = "blue",
val isWattMode: Boolean = false,
val dutyCyclePercent: Double = 100.0,
val dailyUsageHours: Double = 24.0,
val systemId: String? = null,
val remoteIconURLString: String? = null,
val affiliateURLString: String? = null,
val affiliateCountryCode: String? = null,
val bomCompletedItemIDs: List<String> = emptyList(),
)
@Entity(
tableName = "batteries",
indices = [Index("systemId")],
)
data class SavedBattery(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String = "",
val nominalVoltage: Double = 12.8,
val capacityAmpHours: Double = 100.0,
val usableCapacityOverrideFraction: Double? = null,
val chargeVoltage: Double? = null,
val cutOffVoltage: Double? = null,
val minimumTemperatureCelsius: Double? = null,
val maximumTemperatureCelsius: Double? = null,
val chemistryRawValue: String = Chemistry.LIFEPO4.rawValue,
val iconName: String = "battery.100",
val colorName: String = "blue",
val systemId: String? = null,
val affiliateURLString: String? = null,
val affiliateCountryCode: String? = null,
val bomCompletedItemIDs: List<String> = emptyList(),
val timestamp: Long = System.currentTimeMillis(),
)
@Entity(
tableName = "chargers",
indices = [Index("systemId")],
)
data class SavedCharger(
@PrimaryKey val id: String = UUID.randomUUID().toString(),
val name: String = "",
val inputVoltage: Double = 230.0,
val outputVoltage: Double = 14.2,
val maxCurrentAmps: Double = 30.0,
val maxPowerWatts: Double = 0.0,
val iconName: String = "bolt.fill",
val colorName: String = "orange",
val systemId: String? = null,
val timestamp: Long = System.currentTimeMillis(),
val remoteIconURLString: String? = null,
val affiliateURLString: String? = null,
val affiliateCountryCode: String? = null,
val bomCompletedItemIDs: List<String> = emptyList(),
val powerSourceType: String = PowerSourceType.SHORE.rawValue,
)
// --- Derived metrics (mirroring the iOS computed properties) ---
val SavedBattery.chemistry: Chemistry get() = Chemistry.fromRaw(chemistryRawValue)
val SavedBattery.energyWattHours: Double get() = nominalVoltage * capacityAmpHours
val SavedBattery.usableCapacityFraction: Double
get() = (usableCapacityOverrideFraction ?: chemistry.usableCapacityFraction).coerceIn(0.0, 1.0)
val SavedBattery.usableCapacityAmpHours: Double get() = capacityAmpHours * usableCapacityFraction
val SavedBattery.usableEnergyWattHours: Double get() = usableCapacityAmpHours * nominalVoltage
val SavedCharger.sourceType: PowerSourceType get() = PowerSourceType.fromRaw(powerSourceType)
val SavedCharger.effectivePowerWatts: Double
get() = if (maxPowerWatts > 0) maxPowerWatts else outputVoltage * maxCurrentAmps

View File

@@ -0,0 +1,50 @@
package app.voltplan.cable.data.model
/** Battery chemistry. Raw values match the iOS SwiftData strings exactly. */
enum class Chemistry(val rawValue: String) {
AGM("AGM"),
GEL("Gel"),
FLOODED_LEAD_ACID("Flooded Lead Acid"),
LIFEPO4("LiFePO4"),
LITHIUM_ION("Lithium Ion");
/** Display name shown in pickers — the raw value, as on iOS. */
val displayName: String get() = rawValue
/** Fraction of nominal capacity that is usable for this chemistry. */
val usableCapacityFraction: Double
get() = when (this) {
FLOODED_LEAD_ACID -> 0.5
AGM -> 0.5
GEL -> 0.6
LIFEPO4 -> 0.9
LITHIUM_ION -> 0.85
}
companion object {
fun fromRaw(raw: String?): Chemistry = entries.firstOrNull { it.rawValue == raw } ?: LIFEPO4
}
}
/** Charger power source. Raw values match the iOS SwiftData strings exactly. */
enum class PowerSourceType(val rawValue: String) {
SHORE("shore"),
SOLAR("solar"),
WIND("wind"),
GENERATOR("generator"),
ALTERNATOR("alternator");
/** SF Symbol name used for this source (translated to a Material icon at render time). */
val iconName: String
get() = when (this) {
SHORE -> "powerplug"
SOLAR -> "sun.max.fill"
WIND -> "wind"
GENERATOR -> "engine.combustion.fill"
ALTERNATOR -> "bolt.car.fill"
}
companion object {
fun fromRaw(raw: String?): PowerSourceType = entries.firstOrNull { it.rawValue == raw } ?: SHORE
}
}

View File

@@ -0,0 +1,126 @@
package app.voltplan.cable.library
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.util.Locale
data class AffiliateLink(val id: String, val url: String, val country: String?)
/** A component fetched from the VoltPlan PocketBase library. Mirrors `ComponentLibraryItem`. */
data class ComponentLibraryItem(
val id: String,
val name: String,
val translations: Map<String, String>,
val voltageIn: Double?,
val voltageOut: Double?,
val watt: Double?,
val dutyCyclePercent: Double?,
val defaultUtilizationFactorPercent: Double?,
val iconURL: String?,
val affiliateLinks: List<AffiliateLink>,
) {
val displayVoltage: Double? get() = voltageIn ?: voltageOut
val current: Double?
get() {
val v = 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 normalizedDutyCyclePercent: Double? get() = normalizePercent(dutyCyclePercent)
private val normalizedUtilizationFactorPercent: Double? get() = normalizePercent(defaultUtilizationFactorPercent)
val defaultDailyUsageHours: Double? get() = normalizedUtilizationFactorPercent?.let { it / 100.0 * 24.0 }
val primaryAffiliateLink: AffiliateLink? get() = affiliateLink(Locale.getDefault().country)
private fun normalizePercent(value: Double?): Double? {
if (value == null) return null
val v = if (value <= 0) 100.0 else value
return v.coerceIn(0.0, 100.0)
}
private fun resolveLocalizedName(locale: Locale): String? {
if (translations.isEmpty()) return null
val lang = locale.language.lowercase()
val region = locale.country.uppercase()
val candidates = listOfNotNull(
if (region.isNotEmpty()) "${lang}_$region" else null,
if (region.isNotEmpty()) "$lang-$region" else null,
lang,
)
// Normalize translation keys to language/region for matching.
val normalized = translations.mapKeys { (k, _) ->
k.replace('-', '_').let { key ->
val parts = key.split('_')
if (parts.size >= 2) "${parts[0].lowercase()}_${parts[1].uppercase()}" else parts[0].lowercase()
}
}
for (c in candidates) {
val key = c.replace('-', '_')
translations[c]?.let { return it }
normalized[key]?.let { return it }
}
// Fall back to a language-only match.
return normalized.entries.firstOrNull { it.key.startsWith(lang) }?.value
}
private fun affiliateLink(region: String?): AffiliateLink? {
if (affiliateLinks.isEmpty()) return null
val normalized = region?.trim()?.uppercase()?.takeUnless { it.isEmpty() }
if (normalized != null) {
affiliateLinks.firstOrNull { it.country?.uppercase() == normalized }?.let { return it }
}
affiliateLinks.firstOrNull { it.country == null }?.let { return it }
return affiliateLinks.first()
}
companion object {
fun from(record: PbComponentRecord, affiliateLinks: List<AffiliateLink>): ComponentLibraryItem {
val iconUrl = record.icon?.takeIf { it.isNotBlank() }?.let {
"$POCKETBASE_BASE/api/files/${record.collectionId}/${record.id}/$it"
}
return ComponentLibraryItem(
id = record.id,
name = record.name,
translations = flattenTranslations(record.translations),
voltageIn = record.voltageIn,
voltageOut = record.voltageOut,
watt = record.watt,
dutyCyclePercent = record.dutyCycle,
defaultUtilizationFactorPercent = record.defaultUtilizationFactor,
iconURL = iconUrl,
affiliateLinks = affiliateLinks,
)
}
/** Flattens the nested translations object to locale → display name. */
private fun flattenTranslations(element: JsonElement?): Map<String, String> {
val obj = (element as? JsonObject) ?: return emptyMap()
val result = LinkedHashMap<String, String>()
for ((key, value) in obj) {
val name = extractName(value)
if (!name.isNullOrBlank()) result[key] = name
}
return result
}
private fun extractName(value: JsonElement): String? = when (value) {
is JsonPrimitive -> value.contentOrNull
is JsonObject -> value["name"]?.jsonPrimitive?.contentOrNull
?: value["value"]?.jsonPrimitive?.contentOrNull
?: value.values.firstNotNullOfOrNull { (it as? JsonPrimitive)?.contentOrNull?.takeIf { s -> s.isNotBlank() } }
else -> null
}
}
}

View File

@@ -0,0 +1,53 @@
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()
val affiliateByComponent = fetchAffiliateLinks(records.map { it.id })
return records.map { record ->
ComponentLibraryItem.from(record, affiliateByComponent[record.id].orEmpty())
}
}
private suspend fun fetchComponents(): List<PbComponentRecord> {
val all = mutableListOf<PbComponentRecord>()
var page = 1
val perPage = 200
while (true) {
val response = api.components(page = page, perPage = perPage)
all += response.items
val done = (response.totalPages in 1..page) || response.items.size < perPage
if (done) break
page++
}
return all
}
private suspend fun fetchAffiliateLinks(componentIds: List<String>): Map<String, List<AffiliateLink>> {
if (componentIds.isEmpty()) return emptyMap()
val result = HashMap<String, MutableList<AffiliateLink>>()
componentIds.chunked(15).forEach { chunk ->
val filter = chunk.joinToString(" || ") { "component='${it.replace("'", "\\'")}'" }.let { "($it)" }
var page = 1
val perPage = 200
while (true) {
val response = api.affiliateLinks(filter = filter, page = page, perPage = perPage)
response.items.forEach { record ->
val component = record.component ?: return@forEach
val country = record.country?.trim()?.takeUnless { it.isEmpty() }?.uppercase()
result.getOrPut(component) { mutableListOf() }
.add(AffiliateLink(record.id, record.url, country))
}
val done = (response.totalPages in 1..page) || response.items.size < perPage
if (done) break
page++
}
}
// Stable order: by country then url.
return result.mapValues { (_, links) ->
links.sortedWith(compareBy({ it.country ?: "" }, { it.url }))
}
}
}

View File

@@ -0,0 +1,100 @@
package app.voltplan.cable.library
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.ElectricalSystem
import app.voltplan.cable.data.model.SavedLoad
import app.voltplan.cable.ui.systems.SystemIconMapper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class LibraryUiState(
val loading: Boolean = true,
val error: String? = null,
val items: List<ComponentLibraryItem> = emptyList(),
val query: String = "",
) {
val filtered: List<ComponentLibraryItem>
get() {
val q = query.trim()
if (q.isEmpty()) return items
return items.filter {
it.localizedName.contains(q, ignoreCase = true) || it.name.contains(q, ignoreCase = true)
}
}
}
class ComponentLibraryViewModel(
private val app: CableApplication,
private val libraryRepo: ComponentLibraryRepository = ComponentLibraryRepository(),
) : ViewModel() {
private val repo = app.repository
private val _state = MutableStateFlow(LibraryUiState())
val state: StateFlow<LibraryUiState> = _state.asStateFlow()
init { load() }
fun load() {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
runCatching { libraryRepo.fetchAll() }
.onSuccess { _state.value = _state.value.copy(loading = false, items = it) }
.onFailure { _state.value = _state.value.copy(loading = false, error = it.message ?: "Error") }
}
}
fun refresh() = load()
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
/** Adds the chosen component as a load. Returns (via [onDone]) the system id to open, or null to just go back. */
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 baseName = item.localizedName.ifBlank { "Library Load" }
val loadName = repo.uniqueComponentName(systemId, baseName)
val voltage = item.displayVoltage ?: 12.0
val power = item.watt ?: (item.current?.let { it * voltage } ?: 0.0)
val current = item.current ?: if (voltage > 0) power / voltage else 0.0
val affiliate = item.primaryAffiliateLink
val load = SavedLoad(
name = loadName,
voltage = voltage,
current = current,
power = power,
length = 10.0,
crossSection = 1.0,
iconName = "lightbulb",
colorName = "blue",
isWattMode = item.watt != null,
dutyCyclePercent = item.normalizedDutyCyclePercent ?: 100.0,
dailyUsageHours = item.defaultDailyUsageHours ?: 1.0,
systemId = systemId,
remoteIconURLString = item.iconURL,
affiliateURLString = affiliate?.url,
affiliateCountryCode = affiliate?.country,
)
repo.upsertLoad(load)
Analytics.log("Library Load Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
onDone(if (createdNewSystem) systemId else null)
}
}
}

View File

@@ -0,0 +1,83 @@
package app.voltplan.cable.library
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Query
const val POCKETBASE_BASE = "https://base.voltplan.app"
@Serializable
data class PbComponentsResponse(
val page: Int = 1,
val perPage: Int = 0,
val totalPages: Int = 0,
val items: List<PbComponentRecord> = emptyList(),
)
@Serializable
data class PbComponentRecord(
val id: String,
val collectionId: String = "",
val name: String = "",
val translations: JsonElement? = null,
val icon: String? = null,
@SerialName("voltage_in") val voltageIn: Double? = null,
@SerialName("voltage_out") val voltageOut: Double? = null,
val watt: Double? = null,
@SerialName("duty_cycle") val dutyCycle: Double? = null,
@SerialName("default_utilization_factor") val defaultUtilizationFactor: Double? = null,
)
@Serializable
data class PbAffiliateResponse(
val page: Int = 1,
val totalPages: Int = 0,
val items: List<PbAffiliateRecord> = emptyList(),
)
@Serializable
data class PbAffiliateRecord(
val id: String,
val url: String = "",
val component: String? = null,
val country: String? = null,
)
interface PocketBaseApi {
@GET("/api/collections/components/records")
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("page") page: Int,
@Query("perPage") perPage: Int = 200,
): PbComponentsResponse
@GET("/api/collections/affiliate_links/records")
suspend fun affiliateLinks(
@Query("filter") filter: String,
@Query("fields") fields: String = "id,url,component,country",
@Query("page") page: Int,
@Query("perPage") perPage: Int = 200,
): PbAffiliateResponse
companion object {
fun create(): PocketBaseApi {
val json = Json { ignoreUnknownKeys = true; isLenient = true }
val client = OkHttpClient.Builder().build()
return Retrofit.Builder()
.baseUrl(POCKETBASE_BASE)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PocketBaseApi::class.java)
}
}
}

View File

@@ -0,0 +1,87 @@
package app.voltplan.cable.pdf
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import androidx.core.content.FileProvider
import java.io.File
/** A4 portrait at 72 dpi. */
const val PAGE_W = 595
const val PAGE_H = 842
const val MARGIN = 40f
/** Lightweight paginating canvas for the PDF exporters. */
class PdfWriter(private val doc: PdfDocument) {
private var pageNumber = 0
private var page: PdfDocument.Page? = null
var y = MARGIN
private set
val canvas get() = page!!.canvas
fun beginPage() {
page?.let { doc.finishPage(it) }
pageNumber++
page = doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageNumber).create())
y = MARGIN
footer()
}
fun ensure(space: Float) {
if (page == null) beginPage()
else if (y + space > PAGE_H - MARGIN - 20) beginPage()
}
fun text(s: String, size: Float, color: Int = Color.BLACK, bold: Boolean = false, x: Float = MARGIN) {
ensure(size + 6)
val p = Paint().apply {
this.color = color
textSize = size
isAntiAlias = true
if (bold) isFakeBoldText = true
}
y += size
canvas.drawText(s, x, y, p)
y += 4
}
fun gap(h: Float) { y += h }
fun divider() {
ensure(8f)
val p = Paint().apply { color = Color.LTGRAY; strokeWidth = 1f }
canvas.drawLine(MARGIN, y, PAGE_W - MARGIN, y, p)
y += 8
}
private fun footer() {
val p = Paint().apply { color = Color.GRAY; textSize = 9f; isAntiAlias = true }
canvas.drawText("Cable by VoltPlan", PAGE_W - MARGIN - 120, PAGE_H - MARGIN + 10, p)
canvas.drawText("$pageNumber", PAGE_W / 2f, PAGE_H - MARGIN + 10, p)
}
fun finish() { page?.let { doc.finishPage(it) } }
}
object PdfShare {
fun writeToCache(context: Context, fileName: String, doc: PdfDocument): File {
val dir = File(context.cacheDir, "exports").apply { mkdirs() }
val file = File(dir, fileName)
file.outputStream().use { doc.writeTo(it) }
doc.close()
return file
}
fun share(context: Context, file: File) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, null).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}

View File

@@ -0,0 +1,47 @@
package app.voltplan.cable.pdf
import android.content.Context
import android.graphics.Color
import android.graphics.pdf.PdfDocument
import app.voltplan.cable.R
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.bom.BomUiState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** Renders the bill of materials as a PDF and opens the share sheet. */
object SystemBomPdf {
private val ACCENT = Color.rgb(115, 87, 219)
suspend fun exportAndShare(context: Context, state: BomUiState, unit: UnitSystem) {
val file = withContext(Dispatchers.IO) {
val doc = PdfDocument()
val w = PdfWriter(doc)
w.beginPage()
w.text(context.getString(R.string.bom_pdf_header_title), 26f, bold = true)
w.text("${state.systemName}${unit.wireAreaUnit}", 13f, Color.DKGRAY)
w.divider()
if (state.sections.isEmpty()) {
w.text(context.getString(R.string.bom_pdf_placeholder_empty), 14f, Color.DKGRAY)
} else {
state.sections.forEach { section ->
w.gap(8f)
w.text(context.getString(section.category.titleRes), 18f, ACCENT, bold = true)
w.text(context.getString(section.category.subtitleRes), 11f, Color.GRAY)
w.gap(4f)
section.items.forEach { item ->
w.text("${item.metricText}", 12f, ACCENT, bold = true)
w.text(item.title, 14f, Color.BLACK, bold = item.isPrimary)
if (item.detail.isNotBlank()) w.text(item.detail, 11f, Color.DKGRAY)
w.gap(6f)
}
w.divider()
}
}
w.finish()
PdfShare.writeToCache(context, "System-BOM-${System.currentTimeMillis()}.pdf", doc)
}
withContext(Dispatchers.Main) { PdfShare.share(context, file) }
}
}

View File

@@ -0,0 +1,92 @@
package app.voltplan.cable.pdf
import android.content.Context
import android.graphics.Color
import android.graphics.pdf.PdfDocument
import app.voltplan.cable.R
import app.voltplan.cable.calc.ElectricalCalculations
import app.voltplan.cable.calc.SystemMetrics
import app.voltplan.cable.calc.formatDurationHours
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.data.model.chemistry
import app.voltplan.cable.data.model.effectivePowerWatts
import app.voltplan.cable.data.model.energyWattHours
import app.voltplan.cable.data.model.usableCapacityAmpHours
import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.util.Fmt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.Locale
private val ACCENT = Color.rgb(115, 87, 219)
/** Renders a full system overview PDF and opens the Android share sheet. */
object SystemOverviewPdf {
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
val file = withContext(Dispatchers.IO) {
val doc = PdfDocument()
val w = PdfWriter(doc)
val m = state.metrics
val name = state.system?.name ?: "System"
w.beginPage()
w.text(name, 26f, bold = true)
w.text("${context.getString(R.string.overview_pdf_summary_title)}${unit.wireAreaUnit}", 13f, Color.DKGRAY)
w.divider()
// Summary metrics
summaryLine(w, context.getString(R.string.overview_pdf_summary_runtime), m.estimatedRuntimeHours?.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_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_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
if (state.loads.isNotEmpty()) {
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
state.loads.forEach { load ->
w.text(load.name, 14f, bold = true)
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 vdrop = ElectricalCalculations.voltageDropPercentage(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_power)} / ${context.getString(R.string.overview_pdf_load_cable)}", "${Fmt.number(load.power)} W / $gauge")
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)))
w.divider()
}
}
if (state.batteries.isNotEmpty()) {
w.gap(12f); w.text(context.getString(R.string.overview_pdf_batteries_section), 18f, ACCENT, bold = true); w.divider()
state.batteries.forEach { b ->
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_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")
w.divider()
}
}
if (state.chargers.isNotEmpty()) {
w.gap(12f); w.text(context.getString(R.string.overview_pdf_chargers_section), 18f, ACCENT, bold = true); w.divider()
state.chargers.forEach { c ->
w.text(c.name, 14f, bold = true)
summaryLine(w, "${context.getString(R.string.overview_pdf_charger_input)} / ${context.getString(R.string.overview_pdf_charger_output)}", "${Fmt.number(c.inputVoltage)} V / ${Fmt.number(c.outputVoltage)} V")
summaryLine(w, "${context.getString(R.string.overview_pdf_charger_current)} / ${context.getString(R.string.overview_pdf_charger_power)}", "${Fmt.number(c.maxCurrentAmps)} A / ${Fmt.number(c.effectivePowerWatts)} W")
w.divider()
}
}
w.finish()
PdfShare.writeToCache(context, "System-Overview-${System.currentTimeMillis()}.pdf", doc)
}
withContext(Dispatchers.Main) {
app.voltplan.cable.analytics.Analytics.log("Overview PDF Shared", mapOf("system" to (state.system?.name ?: "")))
PdfShare.share(context, file)
}
}
private fun summaryLine(w: PdfWriter, label: String, value: String) {
w.text("$label: $value", 12f, Color.DKGRAY)
}
}

View File

@@ -0,0 +1,9 @@
package app.voltplan.cable.ui
import androidx.compose.runtime.compositionLocalOf
import app.voltplan.cable.data.UnitSystemSettings
/** Provides the app-wide unit settings to the composable tree (set in MainActivity). */
val LocalUnitSettings = compositionLocalOf<UnitSystemSettings> {
error("UnitSystemSettings not provided")
}

View File

@@ -0,0 +1,147 @@
package app.voltplan.cable.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AcUnit
import androidx.compose.material.icons.outlined.Air
import androidx.compose.material.icons.outlined.Apartment
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Battery0Bar
import androidx.compose.material.icons.outlined.Battery3Bar
import androidx.compose.material.icons.outlined.Battery5Bar
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.BatteryFull
import androidx.compose.material.icons.outlined.Build
import androidx.compose.material.icons.outlined.Cabin
import androidx.compose.material.icons.outlined.CameraAlt
import androidx.compose.material.icons.outlined.Computer
import androidx.compose.material.icons.outlined.Construction
import androidx.compose.material.icons.outlined.DirectionsBoat
import androidx.compose.material.icons.outlined.DirectionsBus
import androidx.compose.material.icons.outlined.DirectionsCar
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material.icons.outlined.ElectricCar
import androidx.compose.material.icons.outlined.Flight
import androidx.compose.material.icons.outlined.Hardware
import androidx.compose.material.icons.outlined.Headphones
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.Kitchen
import androidx.compose.material.icons.outlined.Laptop
import androidx.compose.material.icons.outlined.Lightbulb
import androidx.compose.material.icons.outlined.LocalFireDepartment
import androidx.compose.material.icons.outlined.LocalGasStation
import androidx.compose.material.icons.outlined.LocalLaundryService
import androidx.compose.material.icons.outlined.LocalShipping
import androidx.compose.material.icons.outlined.Memory
import androidx.compose.material.icons.outlined.Microwave
import androidx.compose.material.icons.outlined.PhoneIphone
import androidx.compose.material.icons.outlined.Power
import androidx.compose.material.icons.outlined.Print
import androidx.compose.material.icons.outlined.Sailing
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.SettingsInputAntenna
import androidx.compose.material.icons.outlined.Speaker
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storage
import androidx.compose.material.icons.outlined.Thermostat
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.WbSunny
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.ui.graphics.vector.ImageVector
/**
* Maps the SF Symbol names persisted by the iOS app to the closest Material Icon.
* Storing the original symbol strings keeps the two platforms' data models identical;
* this translation happens only at render time.
*/
fun sfSymbol(name: String?): ImageVector = when (name) {
// Lighting / loads
"lightbulb", "lamp.desk" -> Icons.Outlined.Lightbulb
"fan", "wind" -> Icons.Outlined.Air
"tv" -> Icons.Outlined.Tv
"poweroutlet.strip", "poweroutlet.type.c", "powerplug" -> Icons.Outlined.Power
"speaker.wave.2" -> Icons.Outlined.Speaker
"refrigerator" -> Icons.Outlined.Kitchen
"washer", "dishwasher", "dryer" -> Icons.Outlined.LocalLaundryService
"stove", "cooktop", "microwave" -> Icons.Outlined.Microwave
"car" -> Icons.Outlined.DirectionsCar
"bolt.car", "bolt.car.fill" -> Icons.Outlined.ElectricCar
"cpu" -> Icons.Outlined.Memory
"desktopcomputer" -> Icons.Outlined.Computer
"laptopcomputer" -> Icons.Outlined.Laptop
"iphone" -> Icons.Outlined.PhoneIphone
"camera" -> Icons.Outlined.CameraAlt
"gamecontroller", "xbox.logo", "playstation.logo" -> Icons.Outlined.SportsEsports
"headphones" -> Icons.Outlined.Headphones
"printer" -> Icons.Outlined.Print
"wifi" -> Icons.Outlined.Wifi
"antenna.radiowaves.left.and.right" -> Icons.Outlined.SettingsInputAntenna
// Bolts / power / chargers
"bolt", "bolt.fill", "bolt.circle", "bolt.circle.fill", "bolt.horizontal.circle",
"bolt.square", "bolt.square.fill", "bolt.badge.clock", "bolt.badge.a",
"bolt.horizontal", "flashlight.on.fill" -> Icons.Outlined.Bolt
// Batteries
"battery.100", "batteryblock", "car.battery" -> Icons.Outlined.BatteryFull
"battery.100.bolt" -> Icons.Outlined.BatteryChargingFull
"battery.75" -> Icons.Outlined.Battery5Bar
"battery.25" -> Icons.Outlined.Battery3Bar
"battery.0" -> Icons.Outlined.Battery0Bar
// Systems / places
"building.2", "building" -> Icons.Outlined.Apartment
"house" -> Icons.Outlined.Home
"tent" -> Icons.Outlined.Cabin
"sailboat" -> Icons.Outlined.Sailing
"ferry" -> Icons.Outlined.DirectionsBoat
"airplane" -> Icons.Outlined.Flight
"bus" -> Icons.Outlined.DirectionsBus
"truck.box" -> Icons.Outlined.LocalShipping
"server.rack" -> Icons.Outlined.Dns
"externaldrive" -> Icons.Outlined.Storage
"gear" -> Icons.Outlined.Settings
"wrench.adjustable" -> Icons.Outlined.Build
"hammer" -> Icons.Outlined.Hardware
"sun.max", "sun.max.fill" -> Icons.Outlined.WbSunny
"engine.combustion", "engine.combustion.fill" -> Icons.Outlined.Construction
"fuelpump" -> Icons.Outlined.LocalGasStation
"drop" -> Icons.Outlined.WaterDrop
"flame" -> Icons.Outlined.LocalFireDepartment
"snowflake" -> Icons.Outlined.AcUnit
"thermometer" -> Icons.Outlined.Thermostat
else -> Icons.Outlined.Bolt
}
/** Icon options offered in the Load appearance editor (mirrors LoadEditorView). */
val loadIconOptions = listOf(
"lightbulb", "lamp.desk", "fan", "tv", "poweroutlet.strip", "poweroutlet.type.c",
"bolt", "xbox.logo", "playstation.logo", "batteryblock", "speaker.wave.2",
"refrigerator", "washer", "dishwasher", "stove", "microwave", "dryer", "cooktop",
"car", "bolt.car", "engine.combustion", "wrench.adjustable", "cpu", "desktopcomputer",
"laptopcomputer", "iphone", "camera", "gamecontroller", "headphones", "printer",
"wifi", "antenna.radiowaves.left.and.right",
)
/** Icon options offered in the System appearance editor (mirrors SystemEditorView). */
val systemIconOptions = listOf(
"building.2", "house", "building", "tent", "sailboat", "airplane", "ferry", "bus",
"truck.box", "server.rack", "externaldrive", "cpu", "gear", "wrench.adjustable",
"hammer", "lightbulb", "bolt", "powerplug", "battery.100", "sun.max",
"engine.combustion", "fuelpump", "drop", "flame", "snowflake", "thermometer",
)
/** Icon options offered in the Battery appearance editor. */
val batteryIconOptions = listOf(
"battery.100", "battery.100.bolt", "battery.75", "battery.25", "battery.0",
"bolt", "bolt.fill", "bolt.circle", "bolt.horizontal.circle", "powerplug",
"car.battery", "bolt.square", "lightbulb",
)
/** Icon options offered in the Charger appearance editor. */
val chargerIconOptions = listOf(
"bolt.fill", "bolt", "bolt.circle", "bolt.circle.fill", "bolt.horizontal.circle",
"bolt.square", "bolt.square.fill", "bolt.badge.clock", "bolt.badge.a", "powerplug",
"flashlight.on.fill", "battery.100.bolt",
)

View File

@@ -0,0 +1,149 @@
package app.voltplan.cable.ui.batteries
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.BatteryFull
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import app.voltplan.cable.calc.BankWarning
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.chemistry
import app.voltplan.cable.data.model.energyWattHours
import app.voltplan.cable.data.model.usableCapacityAmpHours
import app.voltplan.cable.data.model.usableCapacityFraction
import app.voltplan.cable.ui.components.MetricBadge
import app.voltplan.cable.ui.components.StatsHeader
import app.voltplan.cable.ui.components.StatusBanner
import app.voltplan.cable.ui.components.SummaryMetric
import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.ui.theme.SysBlue
import app.voltplan.cable.ui.theme.SysGreen
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysPurple
import app.voltplan.cable.ui.theme.SysRed
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.util.Fmt
import java.util.Locale
import kotlin.math.roundToInt
@Composable
fun BatteriesTab(
state: DetailState,
onEditBattery: (String) -> Unit,
onNewBattery: () -> Unit,
onDeleteBattery: (SavedBattery) -> Unit,
) {
val batteries = state.batteries
if (batteries.isEmpty()) {
app.voltplan.cable.ui.components.OnboardingInfo(
icon = Icons.Outlined.BatteryFull,
title = stringResource(R.string.battery_onboarding_title),
subtitle = stringResource(R.string.battery_onboarding_subtitle),
primaryLabel = stringResource(R.string.battery_empty_create),
onPrimary = onNewBattery,
)
return
}
val m = state.metrics
Column(Modifier.fillMaxSize()) {
StatsHeader {
Text(stringResource(R.string.battery_bank_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
SummaryMetric(Icons.Outlined.BatteryFull, batteries.size.toString(), stringResource(R.string.battery_metric_count), SysBlue)
SummaryMetric(Icons.Outlined.Speed, "${Fmt.number(m.totalCapacity)} Ah", stringResource(R.string.battery_metric_capacity), SysOrange)
SummaryMetric(Icons.Outlined.BatteryChargingFull, "${Fmt.number(m.totalUsableCapacity)} Ah", stringResource(R.string.battery_metric_usable_capacity), SysPurple)
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalUsableEnergy)} Wh", stringResource(R.string.battery_metric_usable_energy), SysGreen)
}
when (val w = m.bankWarning) {
is BankWarning.Voltage -> Box(Modifier.padding(top = 12.dp)) {
StatusBanner(Icons.Outlined.Warning, stringResource(R.string.battery_banner_voltage), SysRed)
}
is BankWarning.Capacity -> Box(Modifier.padding(top = 12.dp)) {
StatusBanner(Icons.Outlined.Warning, stringResource(R.string.battery_banner_capacity), SysOrange)
}
null -> {}
}
}
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
items(batteries, key = { it.id }) { battery ->
BatteryRow(battery, onClick = { onEditBattery(battery.id) }, onDelete = { onDeleteBattery(battery) })
}
}
}
}
@Composable
private fun BatteryRow(battery: SavedBattery, onClick: () -> Unit, onDelete: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clip(RoundedCornerShape(18.dp)),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
onClick = onClick,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(
Modifier.size(48.dp).clip(RoundedCornerShape(12.dp)).background(componentColor(battery.colorName)),
contentAlignment = Alignment.Center,
) {
Icon(sfSymbol(battery.iconName.ifBlank { "battery.100.bolt" }), contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
}
Column(Modifier.weight(1f)) {
Text(battery.name, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(battery.chemistry.displayName, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(String.format(Locale.US, "%.1f V", battery.nominalVoltage), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_delete), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MetricBadge(stringResource(R.string.battery_badge_voltage), String.format(Locale.US, "%.1f V", battery.nominalVoltage), SysOrange)
MetricBadge(stringResource(R.string.battery_metric_capacity), String.format(Locale.US, "%.1f Ah", battery.capacityAmpHours), SysBlue)
MetricBadge(
stringResource(R.string.battery_metric_usable_capacity),
String.format(Locale.US, "%.1f Ah (%d%%)", battery.usableCapacityAmpHours, (battery.usableCapacityFraction * 100).roundToInt()),
SysPurple,
)
MetricBadge(stringResource(R.string.battery_badge_energy), String.format(Locale.US, "%.1f Wh", battery.energyWattHours), SysGreen)
}
}
}
}

View File

@@ -0,0 +1,260 @@
package app.voltplan.cable.ui.batteries
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.data.model.Chemistry
import app.voltplan.cable.ui.batteryIconOptions
import app.voltplan.cable.ui.components.AppearanceEditorSheet
import app.voltplan.cable.ui.components.MetricBadge
import app.voltplan.cable.ui.components.SnapSlider
import app.voltplan.cable.ui.components.ValueEditDialog
import app.voltplan.cable.ui.theme.SysBlue
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysPurple
import app.voltplan.cable.util.Fmt
import java.util.Locale
import kotlin.math.roundToInt
private enum class BField { VOLTAGE, CAPACITY, USABLE, CHARGE, CUTOFF, MIN_TEMP, MAX_TEMP }
private val V_SNAPS = listOf(6.0, 12.0, 12.8, 24.0, 25.6, 36.0, 48.0, 51.2)
private val CAP_SNAPS = listOf(10.0, 20.0, 50.0, 75.0, 100.0, 125.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1000.0)
private val CHARGE_SNAPS = listOf(13.8, 14.0, 14.2, 14.4, 14.6, 14.8, 15.0)
private val CUTOFF_SNAPS = listOf(10.0, 10.5, 11.0, 11.5, 12.0)
private val TEMP_SNAPS = listOf(-40.0, -20.0, -10.0, 0.0, 25.0, 40.0, 50.0, 60.0)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BatteryEditorScreen(systemId: String, batteryId: String?, onBack: () -> Unit) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: BatteryEditorViewModel = viewModel(
key = "battery-${batteryId ?: "new"}",
factory = viewModelFactory { initializer { BatteryEditorViewModel(app, systemId, batteryId) } },
)
val s by vm.state.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<BField?>(null) }
var showAppearance by remember { mutableStateOf(false) }
var chemistryMenu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
},
title = {
Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true })
},
)
},
) { padding ->
Column(
Modifier.padding(padding).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header chips
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MetricBadge(stringResource(R.string.battery_badge_voltage), String.format(Locale.US, "%.1f V", s.nominalVoltage), SysOrange)
MetricBadge(stringResource(R.string.battery_metric_capacity), String.format(Locale.US, "%.0f Ah", s.capacityAmpHours), SysBlue)
MetricBadge(stringResource(R.string.battery_badge_energy), String.format(Locale.US, "%.0f Wh", s.energyWattHours), SysPurple)
}
// Chemistry picker
Column {
Text(stringResource(R.string.battery_field_chemistry).uppercase(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(
Modifier.fillMaxWidth().clickable { chemistryMenu = true }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(s.chemistry.displayName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Icon(Icons.Outlined.ArrowDropDown, contentDescription = null)
DropdownMenu(expanded = chemistryMenu, onDismissRequest = { chemistryMenu = false }) {
Chemistry.entries.forEach { c ->
DropdownMenuItem(text = { Text(c.displayName) }, onClick = { vm.setChemistry(c); chemistryMenu = false })
}
}
}
}
SnapSlider(
title = stringResource(R.string.battery_slider_voltage),
value = s.nominalVoltage,
range = maxOf(0.0, minOf(6.0, s.nominalVoltage))..maxOf(60.0, s.nominalVoltage),
valueText = String.format(Locale.US, "%.1f V", s.nominalVoltage),
onValueChange = vm::setVoltage, snapValues = V_SNAPS, snapTolerance = 0.5, round = Fmt::roundToTenth,
onEditRequest = { editing = BField.VOLTAGE },
)
SnapSlider(
title = stringResource(R.string.battery_slider_capacity),
value = s.capacityAmpHours,
range = maxOf(0.0, minOf(5.0, s.capacityAmpHours))..maxOf(1000.0, s.capacityAmpHours),
valueText = String.format(Locale.US, "%.0f Ah", s.capacityAmpHours),
onValueChange = vm::setCapacity, snapValues = CAP_SNAPS, snapTolerance = 10.0, round = Fmt::roundToTenth,
onEditRequest = { editing = BField.CAPACITY },
)
// Advanced
Row(Modifier.fillMaxWidth().clickable { vm.toggleAdvanced() }, verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.battery_section_advanced).uppercase(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.weight(1f))
Icon(if (s.advancedExpanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, contentDescription = null)
}
AnimatedVisibility(visible = s.advancedExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SnapSlider(
title = stringResource(R.string.battery_slider_usable_capacity),
value = s.usableFraction * 100,
range = 0.0..100.0,
valueText = "${(s.usableFraction * 100).roundToInt()}%",
onValueChange = vm::setUsablePercent,
snapValues = listOf(s.chemistry.usableCapacityFraction * 100),
snapTolerance = 1.0,
round = Fmt::roundToTenth,
onEditRequest = { editing = BField.USABLE },
)
if (s.usableOverrideFraction != null) {
OutlinedButton(onClick = { vm.resetUsable() }) { Text(stringResource(R.string.battery_button_reset_default)) }
}
Text(
if (s.usableOverrideFraction == null)
stringResource(R.string.battery_usable_footer_default, "${(s.chemistry.usableCapacityFraction * 100).roundToInt()}%")
else
stringResource(R.string.battery_usable_footer_override, "${(s.chemistry.usableCapacityFraction * 100).roundToInt()}%"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SnapSlider(
title = stringResource(R.string.battery_slider_charge_voltage),
value = s.chargeVoltage,
range = maxOf(0.0, minOf(10.0, s.chargeVoltage))..maxOf(60.0, s.chargeVoltage),
valueText = String.format(Locale.US, "%.1f V", s.chargeVoltage),
onValueChange = vm::setChargeVoltage, snapValues = CHARGE_SNAPS, snapTolerance = 0.2, round = Fmt::roundToTenth,
onEditRequest = { editing = BField.CHARGE },
)
Text(stringResource(R.string.battery_charge_helper), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
SnapSlider(
title = stringResource(R.string.battery_slider_cutoff_voltage),
value = s.cutOffVoltage,
range = maxOf(0.0, minOf(5.0, s.cutOffVoltage))..maxOf(60.0, s.chargeVoltage),
valueText = String.format(Locale.US, "%.1f V", s.cutOffVoltage),
onValueChange = vm::setCutoff, snapValues = CUTOFF_SNAPS, snapTolerance = 0.2, round = Fmt::roundToTenth,
onEditRequest = { editing = BField.CUTOFF },
)
Text(stringResource(R.string.battery_cutoff_helper), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.battery_slider_temperature_range), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
SnapSlider(
title = stringResource(R.string.battery_temp_min),
value = s.minTemp,
range = -60.0..minOf(s.maxTemp, 80.0),
valueText = String.format(Locale.US, "%.0f°C", s.minTemp),
onValueChange = vm::setMinTemp, snapValues = TEMP_SNAPS, snapTolerance = 1.5, round = Fmt::roundToTenth,
onEditRequest = { editing = BField.MIN_TEMP },
modifier = Modifier.weight(1f),
)
SnapSlider(
title = stringResource(R.string.battery_temp_max),
value = s.maxTemp,
range = maxOf(s.minTemp, -60.0)..80.0,
valueText = String.format(Locale.US, "%.0f°C", s.maxTemp),
onValueChange = vm::setMaxTemp, snapValues = TEMP_SNAPS, snapTolerance = 1.5, round = Fmt::roundToTenth,
onEditRequest = { editing = BField.MAX_TEMP },
modifier = Modifier.weight(1f),
)
}
Text(stringResource(R.string.battery_temp_helper), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
editing?.let { field ->
val onConfirm: (Double) -> Unit = when (field) {
BField.VOLTAGE -> { v -> vm.setVoltage(Fmt.roundToTenth(v)) }
BField.CAPACITY -> { v -> vm.setCapacity(Fmt.roundToTenth(v)) }
BField.USABLE -> { v -> vm.setUsablePercent(v) }
BField.CHARGE -> { v -> vm.setChargeVoltage(Fmt.roundToTenth(v)) }
BField.CUTOFF -> { v -> vm.setCutoff(Fmt.roundToTenth(v)) }
BField.MIN_TEMP -> { v -> vm.setMinTemp(Fmt.roundToTenth(v)) }
BField.MAX_TEMP -> { v -> vm.setMaxTemp(Fmt.roundToTenth(v)) }
}
val title = when (field) {
BField.VOLTAGE -> stringResource(R.string.battery_alert_voltage_title)
BField.CAPACITY -> stringResource(R.string.battery_alert_capacity_title)
BField.USABLE -> stringResource(R.string.battery_alert_usable_title)
BField.CHARGE -> stringResource(R.string.battery_alert_charge_title)
BField.CUTOFF -> stringResource(R.string.battery_alert_cutoff_title)
BField.MIN_TEMP -> stringResource(R.string.battery_alert_min_temp_title)
BField.MAX_TEMP -> stringResource(R.string.battery_alert_max_temp_title)
}
val initial = when (field) {
BField.VOLTAGE -> s.nominalVoltage
BField.CAPACITY -> s.capacityAmpHours
BField.USABLE -> s.usableFraction * 100
BField.CHARGE -> s.chargeVoltage
BField.CUTOFF -> s.cutOffVoltage
BField.MIN_TEMP -> s.minTemp
BField.MAX_TEMP -> s.maxTemp
}
ValueEditDialog(title = title, message = null, initialValue = initial, onConfirm = onConfirm, onDismiss = { editing = null })
}
if (showAppearance) {
AppearanceEditorSheet(
title = stringResource(R.string.battery_appearance_title),
nameLabel = stringResource(R.string.battery_field_name),
previewSubtitle = stringResource(R.string.battery_appearance_subtitle),
icons = batteryIconOptions,
initialName = s.name,
initialIcon = s.iconName,
initialColor = s.colorName,
onSave = { name, icon, color -> vm.setAppearance(name, icon, color) },
onDismiss = { showAppearance = false },
)
}
}

View File

@@ -0,0 +1,133 @@
package app.voltplan.cable.ui.batteries
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.SavedBattery
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
import kotlin.math.abs
data class BatteryState(
val name: String = "New Battery",
val chemistry: Chemistry = Chemistry.LIFEPO4,
val nominalVoltage: Double = 12.8,
val capacityAmpHours: Double = 100.0,
val usableOverrideFraction: Double? = null,
val chargeVoltage: Double = 14.4,
val cutOffVoltage: Double = 10.8,
val minTemp: Double = -20.0,
val maxTemp: Double = 60.0,
val iconName: String = "battery.100.bolt",
val colorName: String = "blue",
val advancedExpanded: Boolean = false,
) {
val energyWattHours: Double get() = nominalVoltage * capacityAmpHours
val usableFraction: Double get() = (usableOverrideFraction ?: chemistry.usableCapacityFraction).coerceIn(0.0, 1.0)
val usableAmpHours: Double get() = capacityAmpHours * usableFraction
}
class BatteryEditorViewModel(
app: CableApplication,
private val systemId: String,
batteryId: String?,
) : ViewModel() {
private val repo = app.repository
private var id: String = batteryId ?: UUID.randomUUID().toString()
private val isNew = batteryId == null
private var loggedCreate = false
private val _state = MutableStateFlow(BatteryState())
val state: StateFlow<BatteryState> = _state.asStateFlow()
init {
viewModelScope.launch {
if (batteryId != null) {
repo.getBattery(batteryId)?.let { b ->
_state.value = BatteryState(
name = b.name,
chemistry = Chemistry.fromRaw(b.chemistryRawValue),
nominalVoltage = b.nominalVoltage,
capacityAmpHours = b.capacityAmpHours,
usableOverrideFraction = b.usableCapacityOverrideFraction,
chargeVoltage = b.chargeVoltage ?: 14.4,
cutOffVoltage = b.cutOffVoltage ?: 10.8,
minTemp = b.minimumTemperatureCelsius ?: -20.0,
maxTemp = b.maximumTemperatureCelsius ?: 60.0,
iconName = b.iconName,
colorName = b.colorName,
)
}
} else {
val color = repo.getSystem(systemId)?.colorName ?: "blue"
val name = repo.uniqueComponentName(systemId, "New Battery")
_state.value = _state.value.copy(name = name, colorName = color)
}
Analytics.log("Battery Editor Opened", mapOf("source" to if (isNew) "create" else "edit"))
}
}
private fun update(transform: (BatteryState) -> BatteryState) {
_state.value = transform(_state.value)
persist()
}
fun setName(v: String) = update { it.copy(name = v) }
fun setChemistry(c: Chemistry) = update { it.copy(chemistry = c) }
fun setVoltage(v: Double) = update { it.copy(nominalVoltage = v.coerceAtLeast(0.0)) }
fun setCapacity(v: Double) = update { it.copy(capacityAmpHours = v.coerceAtLeast(0.0)) }
fun setUsablePercent(percent: Double) = update { s ->
val fraction = (percent / 100.0).coerceIn(0.0, 1.0)
// Clear the override when it matches the chemistry default.
if (abs(fraction - s.chemistry.usableCapacityFraction) < 0.001) s.copy(usableOverrideFraction = null)
else s.copy(usableOverrideFraction = fraction)
}
fun resetUsable() = update { it.copy(usableOverrideFraction = null) }
fun setChargeVoltage(v: Double) = update { s ->
s.copy(chargeVoltage = v.coerceAtLeast(s.cutOffVoltage))
}
fun setCutoff(v: Double) = update { s ->
s.copy(cutOffVoltage = v.coerceAtMost(s.chargeVoltage))
}
fun setMinTemp(v: Double) = update { s -> s.copy(minTemp = v.coerceAtMost(s.maxTemp)) }
fun setMaxTemp(v: Double) = update { s -> s.copy(maxTemp = v.coerceAtLeast(s.minTemp)) }
fun toggleAdvanced() { _state.value = _state.value.copy(advancedExpanded = !_state.value.advancedExpanded) }
fun setAppearance(name: String, icon: String, color: String) = update { it.copy(name = name, iconName = icon, colorName = color) }
private fun persist() {
val s = _state.value
val battery = SavedBattery(
id = id,
name = s.name,
nominalVoltage = s.nominalVoltage,
capacityAmpHours = s.capacityAmpHours,
usableCapacityOverrideFraction = s.usableOverrideFraction,
chargeVoltage = s.chargeVoltage,
cutOffVoltage = s.cutOffVoltage,
minimumTemperatureCelsius = s.minTemp,
maximumTemperatureCelsius = s.maxTemp,
chemistryRawValue = s.chemistry.rawValue,
iconName = s.iconName,
colorName = s.colorName,
systemId = systemId,
timestamp = System.currentTimeMillis(),
)
viewModelScope.launch {
repo.upsertBattery(battery)
if (isNew && !loggedCreate) {
loggedCreate = true
Analytics.log("Battery Created", mapOf("name" to s.name, "voltage" to s.nominalVoltage, "capacity" to s.capacityAmpHours, "chemistry" to s.chemistry.rawValue))
}
}
}
}

View File

@@ -0,0 +1,220 @@
package app.voltplan.cable.ui.bom
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.loads.CalcState
import app.voltplan.cable.pdf.SystemBomPdf
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BillOfMaterialsScreen(systemId: String, onBack: () -> Unit) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: BillOfMaterialsViewModel = viewModel(
key = "bom-$systemId",
factory = viewModelFactory { initializer { BillOfMaterialsViewModel(app, systemId) } },
)
val state by vm.state.collectAsStateWithLifecycle()
val unit by LocalUnitSettings.current.unitSystem.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
androidx.compose.material3.TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
},
title = { Text(stringResource(R.string.bom_navigation_title)) },
actions = {
IconButton(
enabled = state.sections.isNotEmpty(),
onClick = {
vm.logPdfExported()
scope.launch { SystemBomPdf.exportAndShare(context, state, unit) }
},
) { Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.bom_export_pdf_button)) }
},
)
},
) { padding ->
if (state.sections.isEmpty()) {
Column(Modifier.padding(padding).fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
Text(stringResource(R.string.bom_empty_message), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
return@Scaffold
}
LazyColumn(Modifier.padding(padding).fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp)) {
state.sections.forEach { section ->
item(key = "header-${section.category}") {
Column(Modifier.padding(top = 12.dp, bottom = 4.dp)) {
Text(stringResource(section.category.titleRes), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(stringResource(section.category.subtitleRes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
items(section.items.size, key = { section.items[it].id }) { idx ->
val bomItem = section.items[idx]
ChecklistRow(
title = bomItem.title,
detail = bomItem.detail,
metric = bomItem.metricText,
completed = vm.isCompleted(bomItem, state.completed),
hasLink = bomItem.destination.toUrl() != null,
onToggle = { vm.toggle(bomItem) },
onOpen = {
bomItem.destination.toUrl()?.let { url ->
vm.logItemTapped(bomItem)
vm.markComplete(bomItem)
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
},
)
}
}
}
}
}
@Composable
fun ChecklistRow(
title: String,
detail: String,
metric: String,
completed: Boolean,
hasLink: Boolean,
onToggle: () -> Unit,
onOpen: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clickable { if (hasLink) onOpen() else onToggle() }
.padding(vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
IconButton(onClick = onToggle, modifier = Modifier.size(28.dp)) {
Icon(
if (completed) Icons.Outlined.CheckCircle else Icons.Outlined.RadioButtonUnchecked,
contentDescription = null,
tint = if (completed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Column(Modifier.weight(1f)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
textDecoration = if (completed) TextDecoration.LineThrough else null,
)
if (detail.isNotBlank()) {
Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (metric.isNotBlank()) {
Text(metric, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary)
}
}
if (hasLink) {
Icon(Icons.Outlined.OpenInNew, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
/** Per-load BOM sheet shown from the calculator's "Review parts" button. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoadBillOfMaterialsSheet(state: CalcState, unitSystem: UnitSystem, onDismiss: () -> Unit) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
// Build BOM for this single (transient) load.
val load = app.voltplan.cable.data.model.SavedLoad(
name = state.loadName,
voltage = state.voltage,
current = state.current,
power = state.power,
length = state.length,
crossSection = 1.0,
iconName = state.iconName,
colorName = state.colorName,
isWattMode = state.isWattMode,
affiliateURLString = state.affiliateURLString,
affiliateCountryCode = state.affiliateCountryCode,
)
val sections = remember(state, unitSystem) {
BomBuilder.build(context, listOf(load.copy(length = if (load.length > 0) load.length else 1.0)), emptyList(), emptyList(), unitSystem)
}
val checked = remember { mutableStateMapOf<String, Boolean>() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp).padding(bottom = 24.dp)) {
Text(stringResource(R.string.bom_navigation_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 8.dp))
sections.forEach { section ->
Text(stringResource(section.category.titleRes), style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 8.dp))
section.items.forEach { bomItem ->
ChecklistRow(
title = bomItem.title,
detail = bomItem.detail,
metric = bomItem.metricText,
completed = checked[bomItem.id] == true,
hasLink = bomItem.destination.toUrl() != null,
onToggle = { checked[bomItem.id] = !(checked[bomItem.id] ?: false) },
onOpen = {
bomItem.destination.toUrl()?.let { url ->
checked[bomItem.id] = true
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
},
)
}
}
Text(
stringResource(R.string.affiliate_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
}
}

View File

@@ -0,0 +1,100 @@
package app.voltplan.cable.ui.bom
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class BomUiState(
val systemName: String = "",
val sections: List<BomSection> = emptyList(),
/** componentId -> set of completed logical ids */
val completed: Map<String, Set<String>> = emptyMap(),
val itemCount: Int = 0,
)
class BillOfMaterialsViewModel(
private val app: CableApplication,
private val systemId: String,
) : ViewModel() {
private val repo = app.repository
val state: StateFlow<BomUiState> = combine(
repo.observeSystem(systemId),
repo.observeLoads(systemId),
repo.observeBatteries(systemId),
repo.observeChargers(systemId),
app.settings.unitSystem,
) { system, loads, batteries, chargers, unit ->
val sections = BomBuilder.build(app, loads, batteries, chargers, unit)
val completed = buildMap {
loads.forEach { put(it.id, it.bomCompletedItemIDs.toSet()) }
batteries.forEach { put(it.id, it.bomCompletedItemIDs.toSet()) }
chargers.forEach { put(it.id, it.bomCompletedItemIDs.toSet()) }
}
BomUiState(system?.name ?: "", sections, completed, sections.sumOf { it.items.size })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), BomUiState())
fun isCompleted(item: BomItem, completed: Map<String, Set<String>>): Boolean =
item.storageKeys.all { (compId, logical) -> completed[compId]?.contains(logical) == true }
fun toggle(item: BomItem) {
val current = state.value.completed
val makeComplete = !isCompleted(item, current)
viewModelScope.launch {
for ((compId, logical) in item.storageKeys) {
val load = repo.getLoad(compId)
if (load != null) {
repo.upsertLoad(load.copy(bomCompletedItemIDs = toggleSet(load.bomCompletedItemIDs, logical, makeComplete)))
continue
}
val battery = repo.getBattery(compId)
if (battery != null) {
repo.upsertBattery(battery.copy(bomCompletedItemIDs = toggleSet(battery.bomCompletedItemIDs, logical, makeComplete)))
continue
}
val charger = repo.getCharger(compId)
if (charger != null) {
repo.upsertCharger(charger.copy(bomCompletedItemIDs = toggleSet(charger.bomCompletedItemIDs, logical, makeComplete)))
}
}
}
}
fun markComplete(item: BomItem) {
val current = state.value.completed
if (isCompleted(item, current)) return
toggle(item)
}
fun logItemTapped(item: BomItem) {
val dest = item.destination
val url = dest.toUrl()
val domain = url?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() } ?: "unknown"
Analytics.log(
"BOM Item Tapped",
mapOf(
"item" to item.title,
"category" to item.category.name.lowercase(),
"is_affiliate" to dest.isAffiliate(),
"domain" to domain,
"system" to state.value.systemName,
),
)
}
fun logPdfExported() {
Analytics.log("BOM PDF Exported", mapOf("system" to state.value.systemName, "item_count" to state.value.itemCount))
}
private fun toggleSet(list: List<String>, value: String, add: Boolean): List<String> {
val set = list.toMutableSet()
if (add) set.add(value) else set.remove(value)
return set.toList()
}
}

View File

@@ -0,0 +1,208 @@
package app.voltplan.cable.ui.bom
import android.content.Context
import app.voltplan.cable.R
import app.voltplan.cable.affiliate.AmazonAffiliate
import app.voltplan.cable.calc.ElectricalCalculations
import app.voltplan.cable.data.UnitSystem
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.chemistry
import app.voltplan.cable.data.model.effectivePowerWatts
import app.voltplan.cable.data.model.usableCapacityAmpHours
import java.util.Locale
import kotlin.math.roundToInt
enum class BomCategory(val titleRes: Int, val subtitleRes: Int) {
COMPONENTS(R.string.bom_category_components_title, R.string.bom_category_components_subtitle),
BATTERIES(R.string.bom_category_batteries_title, R.string.bom_category_batteries_subtitle),
CABLES(R.string.bom_category_cables_title, R.string.bom_category_cables_subtitle),
FUSES(R.string.bom_category_fuses_title, R.string.bom_category_fuses_subtitle),
ACCESSORIES(R.string.bom_category_accessories_title, R.string.bom_category_accessories_subtitle),
}
sealed interface BomDestination {
data class Affiliate(val url: String, val country: String?) : BomDestination
data class AmazonSearch(val query: String, val country: String?) : BomDestination
}
/** A single, possibly-merged, line in the bill of materials. */
data class BomItem(
val id: String,
val title: String,
val detail: String,
val symbol: String,
val category: BomCategory,
val quantity: Int,
val isPrimary: Boolean,
val destination: BomDestination,
val metricText: String,
/** (componentId, logicalId) pairs whose completion this item represents. */
val storageKeys: List<Pair<String, String>>,
)
data class BomSection(val category: BomCategory, val items: List<BomItem>)
object BomBuilder {
fun crossSectionLabel(load: SavedLoad, unit: UnitSystem): String {
val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit)
return if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", cs)
else "AWG ${ElectricalCalculations.formatAWG(cs)}"
}
private fun gaugeQuery(load: SavedLoad, unit: UnitSystem): String {
val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit)
return if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", cs)
else "AWG ${ElectricalCalculations.formatAWG(cs)}"
}
fun build(
context: Context,
loads: List<SavedLoad>,
batteries: List<SavedBattery>,
chargers: List<SavedCharger>,
unit: UnitSystem,
): List<BomSection> {
val items = mutableListOf<BomItem>()
val country: String? = java.util.Locale.getDefault().country.ifBlank { null }
// Loads → component / cables / fuse / terminals
for (load in loads.filter { it.length > 0 && it.crossSection > 0 }) {
val name = load.name.ifBlank { context.getString(R.string.component_fallback_name) }
val gauge = crossSectionLabel(load, unit)
val lengthDisplay = LoadLength.display(load.length, unit)
val fuse = ElectricalCalculations.recommendedFuse(load.current).roundToInt()
// Component
val compDest: BomDestination = load.affiliateURLString?.let { BomDestination.Affiliate(it, load.affiliateCountryCode) }
?: BomDestination.AmazonSearch(load.name.ifBlank { context.getString(R.string.bom_search_device_fallback, load.power, load.voltage) }, country)
items += BomItem(
id = "${load.id}::component",
title = name,
detail = String.format(Locale.US, "%.0f W @ %.1f V", load.power, load.voltage),
symbol = "bolt.fill",
category = BomCategory.COMPONENTS,
quantity = 1,
isPrimary = true,
destination = compDest,
metricText = "1×",
storageKeys = listOf(load.id to "component"),
)
// Cables
items += cableItem(context, load, "cable-red", R.string.bom_item_cable_red, R.string.bom_search_cable_red, gauge, lengthDisplay, unit, country)
items += cableItem(context, load, "cable-black", R.string.bom_item_cable_black, R.string.bom_search_cable_black, gauge, lengthDisplay, unit, country)
// Fuse
items += BomItem(
id = "${load.id}::fuse",
title = context.getString(R.string.bom_item_fuse),
detail = context.getString(R.string.bom_fuse_detail, fuse),
symbol = "bolt.square",
category = BomCategory.FUSES,
quantity = 1,
isPrimary = false,
destination = BomDestination.AmazonSearch(context.getString(R.string.bom_search_fuse, fuse), country),
metricText = "1× · ${fuse} A",
storageKeys = listOf(load.id to "fuse"),
)
// Terminals
items += BomItem(
id = "${load.id}::terminals",
title = context.getString(R.string.bom_item_terminals),
detail = context.getString(R.string.bom_terminals_detail, gauge),
symbol = "wrench.adjustable",
category = BomCategory.ACCESSORIES,
quantity = 4,
isPrimary = false,
destination = BomDestination.AmazonSearch(context.getString(R.string.bom_search_terminals, gauge), country),
metricText = "4× · $gauge",
storageKeys = listOf(load.id to "terminals"),
)
}
// Batteries
for (b in batteries) {
val name = b.name.ifBlank { context.getString(R.string.component_fallback_name) }
val dest: BomDestination = b.affiliateURLString?.let { BomDestination.Affiliate(it, b.affiliateCountryCode) }
?: BomDestination.AmazonSearch(
context.getString(R.string.bom_search_battery, b.capacityAmpHours.roundToInt(), b.nominalVoltage.roundToInt(), b.chemistry.displayName), country,
)
items += BomItem(
id = "${b.id}::battery",
title = name,
detail = String.format(Locale.US, "%.0f Ah @ %.1f V • %s • %.0f Ah", b.capacityAmpHours, b.nominalVoltage, b.chemistry.displayName, b.usableCapacityAmpHours),
symbol = b.iconName,
category = BomCategory.BATTERIES,
quantity = 1,
isPrimary = true,
destination = dest,
metricText = "1×",
storageKeys = listOf(b.id to "battery"),
)
}
// Chargers
for (c in chargers) {
val name = c.name.ifBlank { context.getString(R.string.component_fallback_name) }
val dest: BomDestination = c.affiliateURLString?.let { BomDestination.Affiliate(it, c.affiliateCountryCode) }
?: BomDestination.AmazonSearch(
context.getString(R.string.bom_search_charger, c.outputVoltage.roundToInt(), c.maxCurrentAmps.roundToInt()), country,
)
items += BomItem(
id = "${c.id}::charger",
title = name,
detail = String.format(Locale.US, "%.0f W • %.0f A @ %.1f V • %.0f V AC", c.effectivePowerWatts, c.maxCurrentAmps, c.outputVoltage, c.inputVoltage),
symbol = c.iconName,
category = BomCategory.COMPONENTS,
quantity = 1,
isPrimary = true,
destination = dest,
metricText = "1×",
storageKeys = listOf(c.id to "charger"),
)
}
return BomCategory.entries.mapNotNull { cat ->
val catItems = items.filter { it.category == cat }
if (catItems.isEmpty()) null else BomSection(cat, catItems)
}
}
private fun cableItem(
context: Context,
load: SavedLoad,
logical: String,
titleRes: Int,
searchRes: Int,
gauge: String,
lengthDisplay: String,
unit: UnitSystem,
country: String?,
) = BomItem(
id = "${load.id}::$logical",
title = context.getString(titleRes),
detail = "$lengthDisplay$gauge",
symbol = "bolt.horizontal.circle",
category = BomCategory.CABLES,
quantity = 1,
isPrimary = false,
destination = BomDestination.AmazonSearch(context.getString(searchRes, gauge), country),
metricText = "$lengthDisplay · $gauge",
storageKeys = listOf(load.id to logical),
)
}
object LoadLength {
fun display(meters: Double, unit: UnitSystem): String {
val v = if (unit == UnitSystem.IMPERIAL) meters * ElectricalCalculations.FEET_PER_METER else meters
return String.format(Locale.US, "%.1f %s", v, unit.lengthUnit)
}
}
fun BomDestination.toUrl(): String? = when (this) {
is BomDestination.Affiliate -> url
is BomDestination.AmazonSearch -> AmazonAffiliate.searchUrl(query, country)
}
fun BomDestination.isAffiliate(): Boolean = this is BomDestination.Affiliate

View File

@@ -0,0 +1,210 @@
package app.voltplan.cable.ui.chargers
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.data.model.PowerSourceType
import app.voltplan.cable.ui.chargerIconOptions
import app.voltplan.cable.ui.components.AppearanceEditorSheet
import app.voltplan.cable.ui.components.MetricBadge
import app.voltplan.cable.ui.components.SnapSlider
import app.voltplan.cable.ui.components.ValueEditDialog
import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.theme.SysGreen
import app.voltplan.cable.ui.theme.SysIndigo
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysPink
import app.voltplan.cable.util.Fmt
import java.util.Locale
private enum class CField { INPUT, OUTPUT, CURRENT, POWER }
private val INPUT_SNAPS = listOf(12.0, 24.0, 48.0, 120.0, 230.0, 240.0)
private val OUTPUT_SNAPS = listOf(12.0, 12.6, 12.8, 14.2, 24.0, 48.0)
private val CURRENT_SNAPS = listOf(5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 60.0, 80.0, 100.0, 150.0, 200.0)
private val POWER_SNAPS = listOf(100.0, 150.0, 200.0, 250.0, 300.0, 400.0, 500.0, 600.0, 750.0, 1000.0, 1250.0, 1500.0, 2000.0, 2500.0, 3000.0)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChargerEditorScreen(systemId: String, chargerId: String?, onBack: () -> Unit) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: ChargerEditorViewModel = viewModel(
key = "charger-${chargerId ?: "new"}",
factory = viewModelFactory { initializer { ChargerEditorViewModel(app, systemId, chargerId) } },
)
val s by vm.state.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<CField?>(null) }
var showAppearance by remember { mutableStateOf(false) }
var sourceMenu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
},
title = { Text(s.name, fontWeight = FontWeight.SemiBold, modifier = Modifier.clickable { showAppearance = true }) },
)
},
) { padding ->
Column(
Modifier.padding(padding).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header chips
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MetricBadge(stringResource(R.string.chargers_badge_input), String.format(Locale.US, "%.0f V", s.inputVoltage), SysIndigo)
MetricBadge(stringResource(R.string.chargers_badge_output), String.format(Locale.US, "%.1f V", s.outputVoltage), SysGreen)
MetricBadge(stringResource(R.string.chargers_badge_current), String.format(Locale.US, "%.1f A", s.maxCurrentAmps), SysOrange)
MetricBadge(stringResource(R.string.chargers_badge_power), String.format(Locale.US, "%.0f W", s.effectivePowerWatts), SysPink)
}
// Power source picker
Column {
Text(stringResource(R.string.charger_source_type).uppercase(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(
Modifier.fillMaxWidth().clickable { sourceMenu = true }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(sfSymbol(s.sourceType.iconName), contentDescription = null)
Text(sourceLabel(s.sourceType), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Icon(Icons.Outlined.ArrowDropDown, contentDescription = null)
DropdownMenu(expanded = sourceMenu, onDismissRequest = { sourceMenu = false }) {
PowerSourceType.entries.forEach { t ->
DropdownMenuItem(
text = { Text(sourceLabel(t)) },
leadingIcon = { Icon(sfSymbol(t.iconName), contentDescription = null) },
onClick = { vm.setSource(t); sourceMenu = false },
)
}
}
}
}
SnapSlider(
title = stringResource(R.string.charger_field_input_voltage),
value = s.inputVoltage,
range = maxOf(0.0, minOf(12.0, s.inputVoltage))..maxOf(300.0, s.inputVoltage),
valueText = String.format(Locale.US, "%.0f V", s.inputVoltage),
onValueChange = vm::setInputVoltage, snapValues = INPUT_SNAPS, snapTolerance = 2.0, round = Fmt::roundToTenth,
onEditRequest = { editing = CField.INPUT },
)
SnapSlider(
title = stringResource(R.string.charger_field_output_voltage),
value = s.outputVoltage,
range = maxOf(0.0, minOf(10.0, s.outputVoltage))..maxOf(80.0, s.outputVoltage),
valueText = String.format(Locale.US, "%.1f V", s.outputVoltage),
onValueChange = vm::setOutputVoltage, snapValues = OUTPUT_SNAPS, snapTolerance = 0.5, round = Fmt::roundToTenth,
onEditRequest = { editing = CField.OUTPUT },
)
if (s.powerEntryMode == PowerEntryMode.POWER) {
SnapSlider(
title = stringResource(R.string.charger_field_power),
value = s.displayedPower,
range = 0.0..maxOf(3000.0, maxOf(s.maxPowerWatts, s.effectivePowerWatts)),
valueText = String.format(Locale.US, "%.0f W", s.displayedPower),
onValueChange = vm::setPower, snapValues = POWER_SNAPS, snapTolerance = 25.0, round = Fmt::roundToNearestFive,
onEditRequest = { editing = CField.POWER },
trailing = { TextButton(onClick = { vm.switchToCurrentMode() }) { Text(stringResource(R.string.slider_button_ampere)) } },
)
Text(stringResource(R.string.charger_field_power_footer), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
SnapSlider(
title = stringResource(R.string.charger_field_current),
value = s.maxCurrentAmps,
range = maxOf(0.0, minOf(5.0, s.maxCurrentAmps))..maxOf(200.0, s.maxCurrentAmps),
valueText = String.format(Locale.US, "%.1f A", s.maxCurrentAmps),
onValueChange = vm::setCurrent, snapValues = CURRENT_SNAPS, snapTolerance = 2.0, round = Fmt::roundToTenth,
onEditRequest = { editing = CField.CURRENT },
trailing = { TextButton(onClick = { vm.switchToPowerMode() }) { Text(stringResource(R.string.slider_button_watt)) } },
)
}
}
}
editing?.let { field ->
val onConfirm: (Double) -> Unit = when (field) {
CField.INPUT -> { v -> vm.setInputVoltage(Fmt.roundToTenth(v)) }
CField.OUTPUT -> { v -> vm.setOutputVoltage(Fmt.roundToTenth(v)) }
CField.CURRENT -> { v -> vm.setCurrent(Fmt.roundToTenth(v)) }
CField.POWER -> { v -> vm.setPower(Fmt.roundToNearestFive(v)) }
}
val title = when (field) {
CField.INPUT -> stringResource(R.string.charger_alert_input_voltage_title)
CField.OUTPUT -> stringResource(R.string.charger_alert_output_voltage_title)
CField.CURRENT -> stringResource(R.string.charger_alert_current_title)
CField.POWER -> stringResource(R.string.charger_alert_power_title)
}
val initial = when (field) {
CField.INPUT -> s.inputVoltage
CField.OUTPUT -> s.outputVoltage
CField.CURRENT -> s.maxCurrentAmps
CField.POWER -> s.displayedPower
}
ValueEditDialog(title = title, message = null, initialValue = initial, onConfirm = onConfirm, onDismiss = { editing = null })
}
if (showAppearance) {
AppearanceEditorSheet(
title = stringResource(R.string.charger_appearance_title),
nameLabel = stringResource(R.string.charger_field_name),
previewSubtitle = stringResource(R.string.charger_appearance_subtitle),
icons = chargerIconOptions,
initialName = s.name,
initialIcon = s.iconName,
initialColor = s.colorName,
onSave = { name, icon, color -> vm.setAppearance(name, icon, color) },
onDismiss = { showAppearance = false },
)
}
}
@Composable
private fun sourceLabel(t: PowerSourceType): String = stringResource(
when (t) {
PowerSourceType.SHORE -> R.string.charger_source_shore
PowerSourceType.SOLAR -> R.string.charger_source_solar
PowerSourceType.WIND -> R.string.charger_source_wind
PowerSourceType.GENERATOR -> R.string.charger_source_generator
PowerSourceType.ALTERNATOR -> R.string.charger_source_alternator
},
)

View File

@@ -0,0 +1,148 @@
package app.voltplan.cable.ui.chargers
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.LocaleDefaults
import app.voltplan.cable.data.model.PowerSourceType
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.util.Fmt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
enum class PowerEntryMode { CURRENT, POWER }
data class ChargerState(
val name: String = "New Charger",
val sourceType: PowerSourceType = PowerSourceType.SHORE,
val inputVoltage: Double = 230.0,
val outputVoltage: Double = 14.2,
val maxCurrentAmps: Double = 30.0,
val maxPowerWatts: Double = 0.0,
val powerEntryMode: PowerEntryMode = PowerEntryMode.CURRENT,
val lastManualPowerWatts: Double = 0.0,
val iconName: String = "bolt.fill",
val colorName: String = "orange",
) {
val effectivePowerWatts: Double get() = if (maxPowerWatts > 0) maxPowerWatts else outputVoltage * maxCurrentAmps
val displayedPower: Double
get() = when {
maxPowerWatts > 0 -> maxPowerWatts
lastManualPowerWatts > 0 -> lastManualPowerWatts
else -> maxOf(0.0, outputVoltage * maxCurrentAmps)
}
}
class ChargerEditorViewModel(
app: CableApplication,
private val systemId: String,
chargerId: String?,
) : ViewModel() {
private val repo = app.repository
private var id: String = chargerId ?: UUID.randomUUID().toString()
private val isNew = chargerId == null
private var loggedCreate = false
private val _state = MutableStateFlow(ChargerState(inputVoltage = LocaleDefaults.mainsVoltage))
val state: StateFlow<ChargerState> = _state.asStateFlow()
init {
viewModelScope.launch {
if (chargerId != null) {
repo.getCharger(chargerId)?.let { c ->
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
_state.value = ChargerState(
name = c.name,
sourceType = PowerSourceType.fromRaw(c.powerSourceType),
inputVoltage = c.inputVoltage,
outputVoltage = c.outputVoltage,
maxCurrentAmps = current,
maxPowerWatts = c.maxPowerWatts,
powerEntryMode = mode,
lastManualPowerWatts = if (c.maxPowerWatts <= 0) c.outputVoltage * c.maxCurrentAmps else c.maxPowerWatts,
iconName = c.iconName,
colorName = c.colorName,
)
}
} else {
val color = repo.getSystem(systemId)?.colorName ?: "orange"
val name = repo.uniqueComponentName(systemId, "New Charger")
_state.value = _state.value.copy(name = name, colorName = color)
}
Analytics.log("Charger Editor Opened", mapOf("source" to if (isNew) "create" else "edit"))
}
}
private fun update(transform: (ChargerState) -> ChargerState) {
_state.value = transform(_state.value)
persist()
}
fun setName(v: String) = update { it.copy(name = v) }
fun setSource(t: PowerSourceType) = update { it.copy(sourceType = t) }
fun setInputVoltage(v: Double) = update { it.copy(inputVoltage = v.coerceAtLeast(0.0)) }
fun setOutputVoltage(v: Double) = update { s ->
val nv = v.coerceAtLeast(0.0)
if (s.powerEntryMode == PowerEntryMode.POWER && s.maxPowerWatts > 0) {
val voltage = maxOf(nv, 0.1)
s.copy(outputVoltage = nv, maxCurrentAmps = Fmt.roundToTenth(s.maxPowerWatts / voltage))
} else {
s.copy(outputVoltage = nv, lastManualPowerWatts = nv * s.maxCurrentAmps)
}
}
fun setCurrent(a: Double) = update { s ->
val na = a.coerceAtLeast(0.0)
s.copy(maxCurrentAmps = na, maxPowerWatts = 0.0, lastManualPowerWatts = s.outputVoltage * na)
}
fun setPower(w: Double) = update { s ->
val nw = w.coerceAtLeast(0.0)
val voltage = maxOf(s.outputVoltage, 0.1)
s.copy(maxPowerWatts = nw, maxCurrentAmps = if (nw > 0) Fmt.roundToTenth(nw / voltage) else 0.0)
}
fun switchToPowerMode() = update { s ->
val candidate = if (s.lastManualPowerWatts > 0) s.lastManualPowerWatts else s.outputVoltage * s.maxCurrentAmps
val power = if (s.maxPowerWatts <= 0) candidate else s.maxPowerWatts
val voltage = maxOf(s.outputVoltage, 0.1)
s.copy(powerEntryMode = PowerEntryMode.POWER, maxPowerWatts = power, maxCurrentAmps = if (power > 0) Fmt.roundToTenth(power / voltage) else 0.0)
}
fun switchToCurrentMode() = update { s ->
val lastManual = if (s.maxPowerWatts > 0) s.maxPowerWatts else s.lastManualPowerWatts
s.copy(powerEntryMode = PowerEntryMode.CURRENT, maxPowerWatts = 0.0, lastManualPowerWatts = maxOf(lastManual, s.outputVoltage * s.maxCurrentAmps))
}
fun setAppearance(name: String, icon: String, color: String) = update { it.copy(name = name, iconName = icon, colorName = color) }
private fun persist() {
val s = _state.value
val charger = SavedCharger(
id = id,
name = s.name,
inputVoltage = s.inputVoltage,
outputVoltage = s.outputVoltage,
maxCurrentAmps = s.maxCurrentAmps,
maxPowerWatts = s.maxPowerWatts,
iconName = s.iconName,
colorName = s.colorName,
systemId = systemId,
timestamp = System.currentTimeMillis(),
powerSourceType = s.sourceType.rawValue,
)
viewModelScope.launch {
repo.upsertCharger(charger)
if (isNew && !loggedCreate) {
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))
}
}
}
}

View File

@@ -0,0 +1,129 @@
package app.voltplan.cable.ui.chargers
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.BatteryChargingFull
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.effectivePowerWatts
import app.voltplan.cable.ui.components.LoadIcon
import app.voltplan.cable.ui.components.MetricBadge
import app.voltplan.cable.ui.components.StatsHeader
import app.voltplan.cable.ui.components.SummaryMetric
import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.ui.theme.SysBlue
import app.voltplan.cable.ui.theme.SysGreen
import app.voltplan.cable.ui.theme.SysIndigo
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysPink
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.util.Fmt
import java.util.Locale
private fun v(value: Double) = if (value > 0) String.format(Locale.US, "%.1f V", value) else ""
private fun a(value: Double) = if (value > 0) String.format(Locale.US, "%.1f A", value) else ""
private fun w(value: Double) = if (value > 0) String.format(Locale.US, "%.0f W", value) else ""
@Composable
fun ChargersTab(
state: DetailState,
onEditCharger: (String) -> Unit,
onNewCharger: () -> Unit,
onDeleteCharger: (SavedCharger) -> Unit,
) {
val chargers = state.chargers
if (chargers.isEmpty()) {
app.voltplan.cable.ui.components.OnboardingInfo(
icon = Icons.Outlined.Bolt,
title = stringResource(R.string.chargers_onboarding_title),
subtitle = stringResource(R.string.chargers_onboarding_subtitle),
primaryLabel = stringResource(R.string.chargers_onboarding_primary),
onPrimary = onNewCharger,
)
return
}
val m = state.metrics
Column(Modifier.fillMaxSize()) {
StatsHeader {
Text(stringResource(R.string.chargers_summary_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
SummaryMetric(Icons.Outlined.Bolt, chargers.size.toString(), stringResource(R.string.chargers_metric_count), SysBlue)
m.representativeChargerOutput?.let {
SummaryMetric(Icons.Outlined.BatteryChargingFull, String.format(Locale.US, "%.1f V", it), stringResource(R.string.chargers_metric_output), SysGreen)
}
SummaryMetric(Icons.Outlined.Speed, "${Fmt.number(m.totalChargerCurrent)} A", stringResource(R.string.chargers_metric_current), SysOrange)
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysPink)
}
}
LazyColumn(Modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 24.dp)) {
items(chargers, key = { it.id }) { charger ->
ChargerRow(charger, onClick = { onEditCharger(charger.id) }, onDelete = { onDeleteCharger(charger) })
}
}
}
}
@Composable
private fun ChargerRow(charger: SavedCharger, onClick: () -> Unit, onDelete: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clip(RoundedCornerShape(18.dp)),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
onClick = onClick,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LoadIcon(charger.remoteIconURLString, charger.iconName, componentColor(charger.colorName), 48.dp)
Column(Modifier.weight(1f)) {
Text(charger.name, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
"${v(charger.inputVoltage)}${v(charger.outputVoltage)}${a(charger.maxCurrentAmps)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_delete), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MetricBadge(stringResource(R.string.chargers_badge_input), v(charger.inputVoltage), SysIndigo)
MetricBadge(stringResource(R.string.chargers_badge_output), v(charger.outputVoltage), SysGreen)
MetricBadge(stringResource(R.string.chargers_badge_current), a(charger.maxCurrentAmps), SysOrange)
MetricBadge(stringResource(R.string.chargers_badge_power), w(charger.effectivePowerWatts), SysPink)
}
}
}
}

View File

@@ -0,0 +1,176 @@
package app.voltplan.cable.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.ui.theme.curatedColorNames
/**
* Bottom-sheet editor for a component's name, icon and color. Mirrors `ItemEditorView`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppearanceEditorSheet(
title: String,
nameLabel: String,
previewSubtitle: String,
icons: List<String>,
initialName: String,
initialIcon: String,
initialColor: String,
onSave: (name: String, iconName: String, colorName: String) -> Unit,
onDismiss: () -> Unit,
extra: (@Composable () -> Unit)? = null,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var name by remember { mutableStateOf(initialName) }
var icon by remember { mutableStateOf(initialIcon) }
var color by remember { mutableStateOf(initialColor) }
val selectedColor = componentColor(color)
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
TextButton(onClick = { onSave(name, icon, color); onDismiss() }) {
Text("Save", fontWeight = FontWeight.SemiBold)
}
}
// Preview row
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LoadIcon(
remoteIconURLString = null,
fallbackSymbol = icon,
fallbackColor = selectedColor,
size = 60.dp,
)
Column {
Text(
name.ifBlank { nameLabel },
style = MaterialTheme.typography.titleMedium,
)
Text(
previewSubtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(nameLabel) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
extra?.invoke()
Text("Icon", style = MaterialTheme.typography.titleSmall)
LazyVerticalGrid(
columns = GridCells.Fixed(5),
modifier = Modifier.fillMaxWidth().heightForRows(icons.size, 5),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = false,
) {
items(icons) { symbol ->
val selected = symbol == icon
Box(
Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(12.dp))
.background(if (selected) selectedColor else MaterialTheme.colorScheme.surfaceVariant)
.clickable { icon = symbol },
contentAlignment = Alignment.Center,
) {
Icon(
sfSymbol(symbol),
contentDescription = symbol,
tint = if (selected) Color.White else MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp),
)
}
}
}
Text("Color", style = MaterialTheme.typography.titleSmall)
LazyVerticalGrid(
columns = GridCells.Fixed(6),
modifier = Modifier.fillMaxWidth().heightForRows(curatedColorNames.size, 6),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
userScrollEnabled = false,
) {
items(curatedColorNames) { colorName ->
val c = componentColor(colorName)
Box(
Modifier
.aspectRatio(1f)
.clip(CircleShape)
.background(c)
.border(2.dp, if (colorName == color) Color.White else Color.Transparent, CircleShape)
.clickable { color = colorName },
contentAlignment = Alignment.Center,
) {
if (colorName == color) {
Icon(Icons.Filled.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
}
}
}
}
}
}
private fun Modifier.heightForRows(itemCount: Int, columns: Int): Modifier {
val rows = (itemCount + columns - 1) / columns
// ~56dp per cell including spacing; gives a non-scrolling grid inside the sheet.
return this.height((rows * 56).dp)
}

View File

@@ -0,0 +1,67 @@
package app.voltplan.cable.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import app.voltplan.cable.ui.sfSymbol
import coil.compose.SubcomposeAsyncImage
/**
* Renders a component icon: a cached remote image when [remoteIconURLString] is present,
* otherwise a colored rounded tile with the mapped symbol. Mirrors `LoadIconView`.
*/
@Composable
fun LoadIcon(
remoteIconURLString: String?,
fallbackSymbol: String,
fallbackColor: Color,
size: Dp,
modifier: Modifier = Modifier,
) {
val corner = maxOf(6.dp, size / 4)
val shape = RoundedCornerShape(corner)
if (remoteIconURLString.isNullOrBlank()) {
FallbackTile(fallbackSymbol, fallbackColor, size, shape, modifier)
} else {
SubcomposeAsyncImage(
model = remoteIconURLString,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = modifier.size(size).clip(shape),
loading = { FallbackTile(fallbackSymbol, fallbackColor, size, shape) },
error = { FallbackTile(fallbackSymbol, fallbackColor, size, shape) },
)
}
}
@Composable
private fun FallbackTile(
symbol: String,
color: Color,
size: Dp,
shape: RoundedCornerShape,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.size(size).clip(shape).background(color),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = sfSymbol(symbol.ifBlank { "lightbulb" }),
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(size * 0.5f),
)
}
}

View File

@@ -0,0 +1,56 @@
package app.voltplan.cable.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/** Centered empty-state with title/subtitle and 12 actions. Mirrors `OnboardingInfoView`. */
@Composable
fun OnboardingInfo(
icon: ImageVector,
title: String,
subtitle: String,
primaryLabel: String,
onPrimary: () -> Unit,
secondaryLabel: String? = null,
onSecondary: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(56.dp))
Spacer(Modifier.size(16.dp))
Text(title, style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
Spacer(Modifier.size(8.dp))
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.size(24.dp))
Button(onClick = onPrimary, modifier = Modifier.fillMaxWidth()) { Text(primaryLabel) }
if (secondaryLabel != null && onSecondary != null) {
Spacer(Modifier.size(8.dp))
OutlinedButton(onClick = onSecondary, modifier = Modifier.fillMaxWidth()) { Text(secondaryLabel) }
}
}
}

View File

@@ -0,0 +1,120 @@
package app.voltplan.cable.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.voltplan.cable.util.Fmt
import kotlin.math.abs
/**
* Slider with snap-to-common-values and a tappable title/value that opens an editable dialog.
* Reproduces the iOS `SliderSection` + alert-based editing pattern.
*/
@Composable
fun SnapSlider(
title: String,
value: Double,
range: ClosedFloatingPointRange<Double>,
valueText: String,
onValueChange: (Double) -> Unit,
modifier: Modifier = Modifier,
snapValues: List<Double> = emptyList(),
snapTolerance: Double = 0.0,
round: (Double) -> Double = { it },
onEditRequest: (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
) {
Column(modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
title.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = if (onEditRequest != null) Modifier.clickable { onEditRequest() } else Modifier,
)
Text(
valueText,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.padding(start = 8.dp)
.then(if (onEditRequest != null) Modifier.clickable { onEditRequest() } else Modifier),
)
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
trailing?.invoke()
}
Slider(
value = value.coerceIn(range.start, range.endInclusive).toFloat(),
onValueChange = { raw ->
onValueChange(applySnap(raw.toDouble(), snapValues, snapTolerance, round))
},
valueRange = range.start.toFloat()..range.endInclusive.toFloat(),
)
}
}
private fun applySnap(
raw: Double,
snapValues: List<Double>,
tolerance: Double,
round: (Double) -> Double,
): Double {
val rounded = round(raw)
if (snapValues.isEmpty() || tolerance <= 0) return rounded
val nearest = snapValues.minByOrNull { abs(it - rounded) } ?: return rounded
return if (abs(nearest - rounded) <= tolerance) nearest else rounded
}
/** Numeric edit dialog matching the iOS per-field alert. */
@Composable
fun ValueEditDialog(
title: String,
message: String?,
initialValue: Double,
onConfirm: (Double) -> Unit,
onDismiss: () -> Unit,
) {
var text by remember { mutableStateOf(Fmt.number(initialValue)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (message != null) Text(message, style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
},
confirmButton = {
TextButton(onClick = { Fmt.parseInput(text)?.let(onConfirm); onDismiss() }) { Text("Save") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}

View File

@@ -0,0 +1,112 @@
package app.voltplan.cable.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.voltplan.cable.ui.theme.CableTeal
/** Card wrapper for header stats. Mirrors `StatsHeaderContainer`. */
@Composable
fun StatsHeader(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clip(RoundedCornerShape(20.dp))
.background(CableTeal.copy(alpha = 0.12f))
.padding(horizontal = 20.dp, vertical = 18.dp),
) { content() }
}
/** Small label-over-value badge with a tint. Mirrors `ComponentMetricBadgeView`. */
@Composable
fun MetricBadge(label: String, value: String, tint: Color, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(tint.copy(alpha = 0.12f))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
label.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Text(
value,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = tint,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
/** Icon + value + uppercase label, used in header summaries. Mirrors `ComponentSummaryMetricView`. */
@Composable
fun SummaryMetric(icon: ImageVector, value: String, label: String, tint: Color, modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(16.dp))
Text(
value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Text(
label.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
/** Tappable warning/info banner. */
@Composable
fun StatusBanner(symbol: ImageVector, text: String, tint: Color, onClick: (() -> Unit)? = null) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tint.copy(alpha = 0.12f))
.then(if (onClick != null) Modifier.clickable { onClick() } else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(symbol, contentDescription = null, tint = tint, modifier = Modifier.size(18.dp))
Text(text, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
@Composable
fun InfoRowIcon() = Icon(Icons.Outlined.Info, contentDescription = null)

View File

@@ -0,0 +1,144 @@
package app.voltplan.cable.ui.library
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
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.ComponentLibraryViewModel
import app.voltplan.cable.ui.components.LoadIcon
import app.voltplan.cable.ui.theme.SysBlue
import app.voltplan.cable.ui.theme.SysOrange
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComponentLibraryScreen(
targetSystemId: String?,
onBack: () -> Unit,
onOpenSystem: (String) -> Unit,
) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: ComponentLibraryViewModel = viewModel(
factory = viewModelFactory { initializer { ComponentLibraryViewModel(app) } },
)
val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
Analytics.log("Component Library Opened", mapOf("source" to if (targetSystemId != null) "system" else "systems-list"))
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.library_title)) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
},
)
},
) { padding ->
Column(Modifier.padding(padding).fillMaxSize()) {
OutlinedTextField(
value = state.query,
onValueChange = vm::setQuery,
placeholder = { Text(stringResource(R.string.library_search_placeholder)) },
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
when {
state.loading && state.items.isEmpty() -> Centered { CircularProgressIndicator() }
state.error != null -> Centered {
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(32.dp))
Text(stringResource(R.string.library_error_title), fontWeight = FontWeight.SemiBold)
Text(state.error ?: "", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = vm::refresh) { Text(stringResource(R.string.library_retry)) }
}
state.filtered.isEmpty() -> Centered {
Text(stringResource(R.string.library_empty_title), fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.library_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
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()
}
}
}
}
}
}
}
}
@Composable
private fun Centered(content: @Composable () -> Unit) {
Column(
Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) { content() }
}
@Composable
private fun LibraryRow(item: ComponentLibraryItem, onClick: () -> Unit) {
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)
Column(Modifier.weight(1f)) {
Text(item.localizedName, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.titleSmall)
val details = listOfNotNull(item.voltageLabel, item.powerLabel, item.currentLabel)
Text(
if (details.isEmpty()) stringResource(R.string.library_details_coming) else details.joinToString(""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}

View File

@@ -0,0 +1,344 @@
package app.voltplan.cable.ui.loads
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.ExpandLess
import androidx.compose.material.icons.outlined.ExpandMore
import androidx.compose.material.icons.outlined.ShoppingCart
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.calc.ElectricalCalculations
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.bom.LoadBillOfMaterialsSheet
import app.voltplan.cable.ui.components.AppearanceEditorSheet
import app.voltplan.cable.ui.components.LoadIcon
import app.voltplan.cable.ui.components.MetricBadge
import app.voltplan.cable.ui.components.SnapSlider
import app.voltplan.cable.ui.components.ValueEditDialog
import app.voltplan.cable.ui.loadIconOptions
import app.voltplan.cable.ui.theme.SysBlue
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.util.Fmt
import java.util.Locale
private enum class EditField { VOLTAGE, CURRENT, POWER, LENGTH, DUTY, USAGE }
private val VOLTAGE_SNAPS = listOf(3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0)
private val CURRENT_SNAPS = listOf(0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 7.5, 10.0, 12.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 60.0, 75.0, 80.0, 100.0)
private val POWER_SNAPS = listOf(5.0, 10.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 60.0, 75.0, 100.0, 125.0, 150.0, 200.0, 250.0, 300.0, 400.0, 500.0, 600.0, 750.0, 1000.0, 1250.0, 1500.0, 2000.0)
private val LENGTH_SNAPS_METRIC = listOf(0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0, 7.5, 10.0, 12.0, 15.0, 20.0)
private val LENGTH_SNAPS_IMPERIAL = listOf(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 15.0, 20.0, 25.0, 30.0, 40.0, 50.0, 60.0)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalculatorScreen(systemId: String, loadId: String?, onBack: () -> Unit) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: CalculatorViewModel = viewModel(
key = "calc-${loadId ?: "new"}",
factory = viewModelFactory { initializer { CalculatorViewModel(app, systemId, loadId) } },
)
val s by vm.state.collectAsStateWithLifecycle()
val unit by LocalUnitSettings.current.unitSystem.collectAsStateWithLifecycle()
var editing by remember { mutableStateOf<EditField?>(null) }
var showAppearance by remember { mutableStateOf(false) }
var showBom by remember { mutableStateOf(false) }
val factor = if (unit == UnitSystem.IMPERIAL) ElectricalCalculations.FEET_PER_METER else 1.0
val displayLength = s.length * factor
val crossSection = ElectricalCalculations.recommendedCrossSection(s.length, s.current, s.voltage, unit)
val vDropPct = ElectricalCalculations.voltageDropPercentage(s.length, s.current, s.voltage, unit)
val vDrop = ElectricalCalculations.voltageDrop(s.length, s.current, s.voltage, unit)
val pLoss = ElectricalCalculations.powerLoss(s.length, s.current, s.voltage, unit)
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { showAppearance = true },
) {
LoadIcon(s.remoteIconURLString, s.iconName, componentColor(s.colorName), 24.dp)
Text(s.loadName, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 8.dp))
}
},
)
},
) { padding ->
Column(
Modifier.padding(padding).fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Badges
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MetricBadge("Fuse", "${s.recommendedFuseFormatted}A", SysOrange, Modifier.weight(1f))
val wire = if (unit == UnitSystem.METRIC)
String.format(Locale.US, "%.1fm @ %.1fmm²", displayLength, crossSection)
else
String.format(Locale.US, "%.1fft @ %s AWG", displayLength, ElectricalCalculations.formatAWG(crossSection))
MetricBadge("Wire", wire, SysBlue, Modifier.weight(2f))
}
// Results row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
val gauge = if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", crossSection)
else "${ElectricalCalculations.formatAWG(crossSection)} AWG"
Text(gauge, color = SysBlue, style = MaterialTheme.typography.bodyMedium)
Text("", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f m", displayLength)
else String.format(Locale.US, "%.1f ft", displayLength),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.clickable { editing = EditField.LENGTH },
)
Text("", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
String.format(Locale.US, "%.1fV (%.1f%%)", vDrop, vDropPct),
color = if (vDropPct > 5) SysOrange else MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium,
)
Text("", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(String.format(Locale.US, "%.1fW", pLoss), style = MaterialTheme.typography.bodyMedium)
}
// Voltage slider
SnapSlider(
title = stringResource(R.string.slider_voltage_title),
value = s.voltage,
range = 0.0..maxOf(48.0, s.voltage),
valueText = String.format(Locale.US, "%.1fV", s.voltage),
onValueChange = vm::setVoltage,
snapValues = VOLTAGE_SNAPS,
snapTolerance = 0.3,
round = Fmt::roundToTenth,
onEditRequest = { editing = EditField.VOLTAGE },
)
// Current / Power slider with mode toggle
if (s.isWattMode) {
SnapSlider(
title = stringResource(R.string.slider_power_title),
value = s.power,
range = 0.0..maxOf(2000.0, s.power),
valueText = String.format(Locale.US, "%.0fW", s.power),
onValueChange = vm::setPower,
snapValues = POWER_SNAPS,
snapTolerance = 2.5,
round = Fmt::roundToNearestFive,
onEditRequest = { editing = EditField.POWER },
trailing = { TextButton(onClick = { vm.setWattMode(false) }) { Text(stringResource(R.string.slider_button_ampere)) } },
)
} else {
SnapSlider(
title = stringResource(R.string.slider_current_title),
value = s.current,
range = 0.0..maxOf(100.0, s.current),
valueText = String.format(Locale.US, "%.1fA", s.current),
onValueChange = vm::setCurrent,
snapValues = CURRENT_SNAPS,
snapTolerance = 0.3,
round = Fmt::roundToTenth,
onEditRequest = { editing = EditField.CURRENT },
trailing = { TextButton(onClick = { vm.setWattMode(true) }) { Text(stringResource(R.string.slider_button_watt)) } },
)
}
// Length slider (display units)
val lengthSnaps = if (unit == UnitSystem.METRIC) LENGTH_SNAPS_METRIC else LENGTH_SNAPS_IMPERIAL
val lengthMax = if (unit == UnitSystem.METRIC) maxOf(20.0, displayLength) else maxOf(60.0, displayLength)
SnapSlider(
title = stringResource(R.string.slider_length_title, unit.lengthUnit),
value = displayLength,
range = 0.0..lengthMax,
valueText = String.format(Locale.US, "%.1f %s", displayLength, unit.lengthUnit),
onValueChange = { vm.setLengthMeters(it / factor) },
snapValues = lengthSnaps,
snapTolerance = 0.5,
round = Fmt::roundToTenth,
onEditRequest = { editing = EditField.LENGTH },
)
// Advanced section
Row(
Modifier.fillMaxWidth().clickable { vm.toggleAdvanced() },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
stringResource(R.string.calculator_advanced_section_title).uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
Icon(
if (s.advancedExpanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
contentDescription = null,
)
}
AnimatedVisibility(visible = s.advancedExpanded) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
SnapSlider(
title = stringResource(R.string.calculator_advanced_duty_title),
value = s.dutyCyclePercent,
range = 0.0..100.0,
valueText = "${Fmt.number(s.dutyCyclePercent)}%",
onValueChange = vm::setDuty,
round = Fmt::roundToTenth,
onEditRequest = { editing = EditField.DUTY },
)
Text(
stringResource(R.string.calculator_advanced_duty_helper),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SnapSlider(
title = stringResource(R.string.calculator_advanced_usage_title),
value = s.dailyUsageHours,
range = 0.0..24.0,
valueText = "${Fmt.number(s.dailyUsageHours)} ${stringResource(R.string.calculator_advanced_usage_unit)}",
onValueChange = vm::setUsage,
round = Fmt::roundToTenth,
onEditRequest = { editing = EditField.USAGE },
)
Text(
stringResource(R.string.calculator_advanced_usage_helper),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Review parts
Row(
Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f))
.clickable {
Analytics.log(
"Review Parts Tapped",
mapOf("load" to (loadId ?: "new"), "has_affiliate" to (s.affiliateURLString != null)),
)
showBom = true
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Outlined.ShoppingCart, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(
stringResource(R.string.affiliate_button_review_parts),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.SemiBold,
)
}
Text(
stringResource(
if (s.affiliateURLString != null) R.string.affiliate_description_with_link
else R.string.affiliate_description_without_link,
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Field edit dialogs
editing?.let { field ->
val (title, message, initial, onConfirm) = editFieldConfig(field, s, displayLength, unit, vm, factor)
ValueEditDialog(
title = title,
message = message,
initialValue = initial,
onConfirm = onConfirm,
onDismiss = { editing = null },
)
}
if (showAppearance) {
AppearanceEditorSheet(
title = stringResource(R.string.editor_load_title),
nameLabel = stringResource(R.string.editor_load_name),
previewSubtitle = stringResource(R.string.editor_load_preview),
icons = loadIconOptions,
initialName = s.loadName,
initialIcon = s.iconName,
initialColor = s.colorName,
onSave = { name, icon, color -> vm.setAppearance(name, icon, color) },
onDismiss = { showAppearance = false },
)
}
if (showBom) {
LoadBillOfMaterialsSheet(state = s, unitSystem = unit, onDismiss = { showBom = false })
}
}
private data class EditConfig(
val title: String,
val message: String?,
val initial: Double,
val onConfirm: (Double) -> Unit,
)
@Composable
private fun editFieldConfig(
field: EditField,
s: CalcState,
displayLength: Double,
unit: UnitSystem,
vm: CalculatorViewModel,
factor: Double,
): EditConfig = when (field) {
EditField.VOLTAGE -> EditConfig(stringResource(R.string.slider_voltage_title), stringResource(R.string.charger_alert_voltage_message), s.voltage) { vm.setVoltage(Fmt.roundToTenth(it)) }
EditField.CURRENT -> EditConfig(stringResource(R.string.slider_current_title), stringResource(R.string.charger_alert_current_message), s.current) { vm.setCurrent(Fmt.roundToTenth(it)) }
EditField.POWER -> EditConfig(stringResource(R.string.slider_power_title), stringResource(R.string.charger_alert_power_message), s.power) { vm.setPower(Fmt.roundToNearestFive(it)) }
EditField.LENGTH -> EditConfig(stringResource(R.string.slider_length_title, unit.lengthUnit), null, displayLength) { vm.setLengthMeters(Fmt.roundToTenth(it) / factor) }
EditField.DUTY -> EditConfig(stringResource(R.string.calculator_alert_duty_title), stringResource(R.string.calculator_alert_duty_message), s.dutyCyclePercent) { vm.setDuty(Fmt.roundToTenth(it)) }
EditField.USAGE -> EditConfig(stringResource(R.string.calculator_alert_usage_title), stringResource(R.string.calculator_alert_usage_message), s.dailyUsageHours) { vm.setUsage(Fmt.roundToTenth(it)) }
}

View File

@@ -0,0 +1,150 @@
package app.voltplan.cable.ui.loads
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.calc.ElectricalCalculations
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.data.model.SavedLoad
import app.voltplan.cable.util.Fmt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
data class CalcState(
val loadName: String = "My Load",
val voltage: Double = 12.0,
val current: Double = 5.0,
val power: Double = 60.0,
val length: Double = 10.0, // metres
val isWattMode: Boolean = false,
val dutyCyclePercent: Double = 100.0,
val dailyUsageHours: Double = 24.0,
val iconName: String = "lightbulb",
val colorName: String = "blue",
val remoteIconURLString: String? = null,
val affiliateURLString: String? = null,
val affiliateCountryCode: String? = null,
val advancedExpanded: Boolean = false,
val bomCompletedItemIDs: List<String> = emptyList(),
) {
val recommendedFuse: Double get() = ElectricalCalculations.recommendedFuse(current)
val recommendedFuseFormatted: String get() = Fmt.fuse(recommendedFuse)
}
class CalculatorViewModel(
app: CableApplication,
private val systemId: String,
loadId: String?,
) : ViewModel() {
private val repo = app.repository
private val _state = MutableStateFlow(CalcState())
val state: StateFlow<CalcState> = _state.asStateFlow()
/** Persistent identity of the load row (created lazily for new loads). */
private var id: String = loadId ?: UUID.randomUUID().toString()
private val isNew: Boolean = loadId == null
init {
if (loadId != null) {
viewModelScope.launch {
repo.getLoad(loadId)?.let { l ->
_state.value = CalcState(
loadName = l.name,
voltage = l.voltage,
current = l.current,
power = l.power,
length = l.length,
isWattMode = l.isWattMode,
dutyCyclePercent = l.dutyCyclePercent,
dailyUsageHours = l.dailyUsageHours,
iconName = l.iconName,
colorName = l.colorName,
remoteIconURLString = l.remoteIconURLString,
affiliateURLString = l.affiliateURLString,
affiliateCountryCode = l.affiliateCountryCode,
bomCompletedItemIDs = l.bomCompletedItemIDs,
)
Analytics.log("Load Opened", mapOf("mode" to mode(), "system" to systemName()))
}
}
} else {
// Mirror SystemComponentsPersistence default load values.
_state.value = CalcState()
persist(created = true)
}
}
private fun mode() = if (_state.value.isWattMode) "watt" else "amp"
private suspend fun systemName() = repo.getSystem(systemId)?.name ?: ""
private fun update(transform: (CalcState) -> CalcState) {
_state.value = transform(_state.value)
persist(created = false)
}
fun setVoltage(v: Double) = update { s ->
val nv = v.coerceAtLeast(0.0)
if (s.isWattMode) s.copy(voltage = nv, current = if (nv > 0) s.power / nv else 0.0)
else s.copy(voltage = nv, power = nv * s.current)
}
fun setCurrent(a: Double) = update { s ->
val na = a.coerceAtLeast(0.0)
s.copy(current = na, power = s.voltage * na, isWattMode = false)
}
fun setPower(w: Double) = update { s ->
val nw = w.coerceAtLeast(0.0)
s.copy(power = nw, current = if (s.voltage > 0) nw / s.voltage else 0.0, isWattMode = true)
}
fun setLengthMeters(m: Double) = update { it.copy(length = m.coerceAtLeast(0.0)) }
fun setDuty(p: Double) = update { it.copy(dutyCyclePercent = p.coerceIn(0.0, 100.0)) }
fun setUsage(h: Double) = update { it.copy(dailyUsageHours = h.coerceIn(0.0, 24.0)) }
fun setName(name: String) = update { it.copy(loadName = name) }
fun toggleAdvanced() { _state.value = _state.value.copy(advancedExpanded = !_state.value.advancedExpanded) }
fun setWattMode(enabled: Boolean) = update { s ->
if (enabled) s.copy(isWattMode = true, current = if (s.voltage > 0) s.power / s.voltage else 0.0)
else s.copy(isWattMode = false, power = s.voltage * s.current)
}
fun setAppearance(name: String, icon: String, color: String) = update {
it.copy(loadName = name, iconName = icon, colorName = color)
}
private fun persist(created: Boolean) {
val s = _state.value
val load = SavedLoad(
id = id,
name = s.loadName,
voltage = s.voltage,
current = s.current,
power = s.power,
length = s.length,
crossSection = ElectricalCalculations.recommendedCrossSection(s.length, s.current, s.voltage, UnitSystem.METRIC),
timestamp = System.currentTimeMillis(),
iconName = s.iconName,
colorName = s.colorName,
isWattMode = s.isWattMode,
dutyCyclePercent = s.dutyCyclePercent,
dailyUsageHours = s.dailyUsageHours,
systemId = systemId,
remoteIconURLString = s.remoteIconURLString,
affiliateURLString = s.affiliateURLString,
affiliateCountryCode = s.affiliateCountryCode,
bomCompletedItemIDs = s.bomCompletedItemIDs,
)
viewModelScope.launch {
repo.upsertLoad(load)
if (created && isNew) {
Analytics.log("Load Created", mapOf("name" to load.name, "system" to systemName()))
}
}
}
}

View File

@@ -0,0 +1,155 @@
package app.voltplan.cable.ui.loads
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.LibraryBooks
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.data.model.SavedLoad
import app.voltplan.cable.ui.components.LoadIcon
import app.voltplan.cable.ui.components.MetricBadge
import app.voltplan.cable.ui.components.StatsHeader
import app.voltplan.cable.ui.components.StatusBanner
import app.voltplan.cable.ui.components.SummaryMetric
import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.ui.theme.SysGreen
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysPink
import app.voltplan.cable.ui.theme.SysTeal
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.util.Fmt
@Composable
fun ComponentsTab(
state: DetailState,
unitSystem: UnitSystem,
onOpenLoad: (String) -> Unit,
onNewLoad: () -> Unit,
onOpenLibrary: () -> Unit,
onDeleteLoad: (SavedLoad) -> Unit,
) {
val loads = state.loads
if (loads.isEmpty()) {
app.voltplan.cable.ui.components.OnboardingInfo(
icon = Icons.Outlined.Layers,
title = stringResource(R.string.loads_onboarding_title),
subtitle = stringResource(R.string.loads_onboarding_subtitle),
primaryLabel = stringResource(R.string.loads_empty_create),
onPrimary = onNewLoad,
secondaryLabel = stringResource(R.string.loads_empty_library),
onSecondary = onOpenLibrary,
)
return
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
StatsHeader {
Text(
stringResource(R.string.loads_overview_header_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
SummaryMetric(Icons.Outlined.Layers, loads.size.toString(), stringResource(R.string.loads_metric_count), MaterialTheme.colorScheme.primary)
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(state.metrics.totalCurrent)} A", stringResource(R.string.loads_metric_current), SysOrange)
SummaryMetric(Icons.Outlined.Speed, "${Fmt.number(state.metrics.totalPower)} W", stringResource(R.string.loads_metric_power), SysGreen)
}
val missing = state.metrics.loadsMissingDetails
if (missing > 0) {
Box(Modifier.padding(top = 12.dp)) {
StatusBanner(
symbol = Icons.Outlined.Warning,
text = stringResource(R.string.loads_status_missing_banner),
tint = SysOrange,
)
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
) {
items(loads, key = { it.id }) { load ->
LoadRow(load, unitSystem, onClick = { onOpenLoad(load.id) }, onDelete = { onDeleteLoad(load) })
}
}
}
ExtendedFloatingActionButton(
onClick = onOpenLibrary,
icon = { Icon(Icons.Outlined.LibraryBooks, contentDescription = null) },
text = { Text(stringResource(R.string.loads_library_button)) },
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
)
}
}
@Composable
private fun LoadRow(load: SavedLoad, unit: UnitSystem, onClick: () -> Unit, onDelete: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp).clip(RoundedCornerShape(18.dp)),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
onClick = onClick,
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LoadIcon(load.remoteIconURLString, load.iconName, componentColor(load.colorName), 48.dp)
Column(Modifier.weight(1f)) {
Text(load.name, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge)
Text(
LoadFormatting.summaryBadge(load, unit),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_delete), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MetricBadge(stringResource(R.string.loads_metric_fuse), LoadFormatting.fuseString(load), SysPink, Modifier.weight(1f))
MetricBadge(stringResource(R.string.loads_metric_cable), LoadFormatting.wireGaugeString(load, unit), SysTeal, Modifier.weight(1f))
MetricBadge(stringResource(R.string.loads_metric_length), LoadFormatting.lengthString(load, unit), SysOrange, Modifier.weight(1f))
}
}
}
}

View File

@@ -0,0 +1,40 @@
package app.voltplan.cable.ui.loads
import app.voltplan.cable.calc.ElectricalCalculations
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.data.model.SavedLoad
import app.voltplan.cable.util.Fmt
import java.util.Locale
/** Display helpers for loads, honouring the active unit system. Mirrors `LoadsView` formatting. */
object LoadFormatting {
fun displayLengthMeters(meters: Double, unit: UnitSystem): Double =
if (unit == UnitSystem.IMPERIAL) meters * ElectricalCalculations.FEET_PER_METER else meters
fun wireGaugeString(load: SavedLoad, unit: UnitSystem): String {
val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit)
return if (unit == UnitSystem.METRIC) {
String.format(Locale.US, "%.1f mm²", cs)
} else {
"${ElectricalCalculations.formatAWG(cs)} AWG"
}
}
fun lengthString(load: SavedLoad, unit: UnitSystem): String {
val len = displayLengthMeters(load.length, unit)
return if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f m", len)
else String.format(Locale.US, "%.1f ft", len)
}
fun fuseString(load: SavedLoad): String =
Fmt.fuse(ElectricalCalculations.recommendedFuse(load.current)) + " A"
/** "{V}V • {power|current} • {length}" summary line shown on load rows. */
fun summaryBadge(load: SavedLoad, unit: UnitSystem): String {
val v = String.format(Locale.US, "%.1fV", load.voltage)
val mid = if (load.isWattMode) String.format(Locale.US, "%.0fW", load.power)
else String.format(Locale.US, "%.1fA", load.current)
return "$v$mid${lengthString(load, unit)}"
}
}

View File

@@ -0,0 +1,141 @@
package app.voltplan.cable.ui.navigation
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import app.voltplan.cable.ui.batteries.BatteryEditorScreen
import app.voltplan.cable.ui.bom.BillOfMaterialsScreen
import app.voltplan.cable.ui.chargers.ChargerEditorScreen
import app.voltplan.cable.ui.library.ComponentLibraryScreen
import app.voltplan.cable.ui.loads.CalculatorScreen
import app.voltplan.cable.ui.settings.SettingsScreen
import app.voltplan.cable.ui.system.SystemDetailScreen
import app.voltplan.cable.ui.systems.SystemsScreen
object Routes {
const val SYSTEMS = "systems"
const val SYSTEM = "system/{systemId}"
const val CALCULATOR = "calculator/{systemId}?loadId={loadId}"
const val BATTERY = "battery/{systemId}?batteryId={batteryId}"
const val CHARGER = "charger/{systemId}?chargerId={chargerId}"
const val BOM = "bom/{systemId}"
const val LIBRARY = "library?systemId={systemId}"
const val SETTINGS = "settings"
fun system(id: String) = "system/$id"
fun library(systemId: String? = null) = "library" + (systemId?.let { "?systemId=$it" } ?: "")
fun calculator(systemId: String, loadId: String? = null) =
"calculator/$systemId" + (loadId?.let { "?loadId=$it" } ?: "")
fun battery(systemId: String, batteryId: String? = null) =
"battery/$systemId" + (batteryId?.let { "?batteryId=$it" } ?: "")
fun charger(systemId: String, chargerId: String? = null) =
"charger/$systemId" + (chargerId?.let { "?chargerId=$it" } ?: "")
fun bom(systemId: String) = "bom/$systemId"
}
@Composable
fun CableNavHost() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = Routes.SYSTEMS) {
composable(Routes.SYSTEMS) {
SystemsScreen(
onOpenSystem = { nav.navigate(Routes.system(it)) },
onOpenSettings = { nav.navigate(Routes.SETTINGS) },
onOpenLibrary = { nav.navigate(Routes.library()) },
)
}
composable(
Routes.SYSTEM,
arguments = listOf(navArgument("systemId") { type = NavType.StringType }),
) { entry ->
val systemId = entry.arguments?.getString("systemId").orEmpty()
SystemDetailScreen(
systemId = systemId,
onBack = { nav.popBackStack() },
onOpenLoad = { loadId -> nav.navigate(Routes.calculator(systemId, loadId)) },
onNewLoad = { nav.navigate(Routes.calculator(systemId)) },
onEditBattery = { id -> nav.navigate(Routes.battery(systemId, id)) },
onNewBattery = { nav.navigate(Routes.battery(systemId)) },
onEditCharger = { id -> nav.navigate(Routes.charger(systemId, id)) },
onNewCharger = { nav.navigate(Routes.charger(systemId)) },
onOpenBom = { nav.navigate(Routes.bom(systemId)) },
onOpenLibrary = { nav.navigate(Routes.library(systemId)) },
)
}
composable(
Routes.CALCULATOR,
arguments = listOf(
navArgument("systemId") { type = NavType.StringType },
navArgument("loadId") { type = NavType.StringType; nullable = true; defaultValue = null },
),
) { entry ->
CalculatorScreen(
systemId = entry.arguments?.getString("systemId").orEmpty(),
loadId = entry.arguments?.getString("loadId"),
onBack = { nav.popBackStack() },
)
}
composable(
Routes.BATTERY,
arguments = listOf(
navArgument("systemId") { type = NavType.StringType },
navArgument("batteryId") { type = NavType.StringType; nullable = true; defaultValue = null },
),
) { entry ->
BatteryEditorScreen(
systemId = entry.arguments?.getString("systemId").orEmpty(),
batteryId = entry.arguments?.getString("batteryId"),
onBack = { nav.popBackStack() },
)
}
composable(
Routes.CHARGER,
arguments = listOf(
navArgument("systemId") { type = NavType.StringType },
navArgument("chargerId") { type = NavType.StringType; nullable = true; defaultValue = null },
),
) { entry ->
ChargerEditorScreen(
systemId = entry.arguments?.getString("systemId").orEmpty(),
chargerId = entry.arguments?.getString("chargerId"),
onBack = { nav.popBackStack() },
)
}
composable(
Routes.BOM,
arguments = listOf(navArgument("systemId") { type = NavType.StringType }),
) { entry ->
BillOfMaterialsScreen(
systemId = entry.arguments?.getString("systemId").orEmpty(),
onBack = { nav.popBackStack() },
)
}
composable(
Routes.LIBRARY,
arguments = listOf(navArgument("systemId") { type = NavType.StringType; nullable = true; defaultValue = null }),
) { entry ->
ComponentLibraryScreen(
targetSystemId = entry.arguments?.getString("systemId"),
onBack = { nav.popBackStack() },
onOpenSystem = { systemId ->
nav.popBackStack()
nav.navigate(Routes.system(systemId))
},
)
}
composable(Routes.SETTINGS) {
SettingsScreen(onBack = { nav.popBackStack() })
}
}
}

View File

@@ -0,0 +1,89 @@
package app.voltplan.cable.ui.overview
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import kotlin.math.roundToInt
private const val MIN_HOURS = 0.5
private const val MAX_HOURS = 240.0
/** Day/hour/minute goal editor with stepper controls. Mirrors the iOS `GoalEditorSheet`. */
@Composable
fun GoalEditorDialog(
title: String,
initialHours: Double,
hasGoal: Boolean,
onSave: (Double) -> Unit,
onRemove: () -> Unit,
onDismiss: () -> Unit,
) {
val clamped = initialHours.coerceIn(MIN_HOURS, MAX_HOURS)
val totalMinutes = (clamped * 60).roundToInt()
var days by remember { mutableIntStateOf(totalMinutes / (24 * 60)) }
var hours by remember { mutableIntStateOf((totalMinutes % (24 * 60)) / 60) }
var minutes by remember { mutableIntStateOf(((totalMinutes % 60) / 15) * 15) }
fun currentHours(): Double = (days * 24 + hours + minutes / 60.0)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Stepper(stringResource(R.string.goal_days), days, 0, 10) { days = it }
Stepper(stringResource(R.string.goal_hours), hours, 0, 23) { hours = it }
Stepper(stringResource(R.string.goal_minutes), minutes, 0, 45, step = 15) { minutes = it }
}
},
confirmButton = {
TextButton(onClick = {
val rounded = (currentHours() / 0.25).roundToInt() * 0.25
onSave(rounded.coerceIn(MIN_HOURS, MAX_HOURS)); onDismiss()
}) { Text(stringResource(R.string.overview_goal_save)) }
},
dismissButton = {
Row {
if (hasGoal) {
TextButton(onClick = { onRemove(); onDismiss() }) { Text(stringResource(R.string.overview_goal_clear)) }
}
TextButton(onClick = onDismiss) { Text(stringResource(R.string.overview_goal_cancel)) }
}
},
)
}
@Composable
private fun Stepper(label: String, value: Int, min: Int, max: Int, step: Int = 1, onChange: (Int) -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(label, modifier = Modifier.weight(1f))
IconButton(onClick = { if (value - step >= min) onChange(value - step) }) {
Icon(Icons.Outlined.Remove, contentDescription = null)
}
Text("$value", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
IconButton(onClick = { if (value + step <= max) onChange(value + step) }) {
Icon(Icons.Outlined.Add, contentDescription = null)
}
}
}

View File

@@ -0,0 +1,229 @@
package app.voltplan.cable.ui.overview
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.BatteryFull
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.ListAlt
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Speed
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import app.voltplan.cable.calc.SystemMetrics
import app.voltplan.cable.calc.formatDurationHours
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.components.StatsHeader
import app.voltplan.cable.ui.components.SummaryMetric
import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.ui.theme.SysBlue
import app.voltplan.cable.ui.theme.SysGreen
import app.voltplan.cable.ui.theme.SysOrange
import app.voltplan.cable.ui.theme.SysPurple
import app.voltplan.cable.util.Fmt
@Composable
fun OverviewTab(
state: DetailState,
unitSystem: UnitSystem,
onAddLoad: () -> Unit,
onAddBattery: () -> Unit,
onAddCharger: () -> Unit,
onOpenLibrary: () -> Unit,
onOpenBom: () -> Unit,
onSetRuntimeGoal: (Double?) -> Unit,
onSetChargeGoal: (Double?) -> Unit,
) {
val m = state.metrics
val system = state.system
var goalEditor by remember { mutableStateOf<GoalKind?>(null) }
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(bottom = 24.dp)) {
StatsHeader {
Text(stringResource(R.string.overview_system_header_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Column(Modifier.padding(top = 8.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
MetricRow(
icon = Icons.Outlined.Schedule,
tint = SysOrange,
title = stringResource(R.string.overview_runtime_title),
subtitle = stringResource(R.string.overview_runtime_subtitle),
value = m.estimatedRuntimeHours?.let { formatDurationHours(it) }
?: if (state.batteries.isEmpty() || state.loads.isEmpty()) stringResource(R.string.overview_runtime_placeholder) else "",
goalHours = system?.targetRuntimeHours,
progress = goalProgress(m.estimatedRuntimeHours, system?.targetRuntimeHours, charge = false),
onClick = { goalEditor = GoalKind.RUNTIME },
)
MetricRow(
icon = Icons.Outlined.Bolt,
tint = SysBlue,
title = stringResource(R.string.overview_chargetime_title),
subtitle = stringResource(R.string.overview_chargetime_subtitle),
value = m.estimatedChargeHours?.let { formatDurationHours(it) }
?: if (state.chargers.isEmpty()) stringResource(R.string.overview_chargetime_placeholder) else "",
goalHours = system?.targetChargeTimeHours,
progress = goalProgress(m.estimatedChargeHours, system?.targetChargeTimeHours, charge = true),
onClick = { goalEditor = GoalKind.CHARGE },
)
MetricRow(
icon = Icons.Outlined.ListAlt,
tint = SysPurple,
title = stringResource(R.string.overview_bom_title),
subtitle = stringResource(R.string.overview_bom_subtitle),
value = if (m.bomItemsCount > 0) "${m.completedBomItemCount}/${m.bomItemsCount}" else stringResource(R.string.overview_bom_placeholder),
goalHours = null,
progress = m.bomCompletionFraction?.toFloat(),
onClick = onOpenBom,
)
}
}
LoadsCard(state, m, onAddLoad, onOpenLibrary)
BatteriesCard(state, m, onAddBattery)
ChargersCard(state, m, onAddCharger)
}
goalEditor?.let { kind ->
val currentGoal = if (kind == GoalKind.RUNTIME) system?.targetRuntimeHours else system?.targetChargeTimeHours
val estimate = if (kind == GoalKind.RUNTIME) m.estimatedRuntimeHours else m.estimatedChargeHours
GoalEditorDialog(
title = stringResource(if (kind == GoalKind.RUNTIME) R.string.overview_runtime_goal_title else R.string.overview_chargetime_goal_title),
initialHours = currentGoal ?: estimate ?: 8.0,
hasGoal = currentGoal != null,
onSave = { hours -> if (kind == GoalKind.RUNTIME) onSetRuntimeGoal(hours) else onSetChargeGoal(hours) },
onRemove = { if (kind == GoalKind.RUNTIME) onSetRuntimeGoal(null) else onSetChargeGoal(null) },
onDismiss = { goalEditor = null },
)
}
}
enum class GoalKind { RUNTIME, CHARGE }
private fun goalProgress(actual: Double?, goal: Double?, charge: Boolean): Float? {
if (actual == null || goal == null || goal <= 0) return null
return if (charge) (goal / maxOf(actual, 0.0001)).coerceIn(0.0, 1.0).toFloat()
else (actual / goal).coerceIn(0.0, 1.0).toFloat()
}
@Composable
private fun MetricRow(
icon: ImageVector,
tint: Color,
title: String,
subtitle: String,
value: String,
goalHours: Double?,
progress: Float?,
onClick: () -> Unit,
) {
Column(Modifier.fillMaxWidth().clickable { onClick() }) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Column(horizontalAlignment = Alignment.End) {
Text(value, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
if (goalHours != null) {
Text(stringResource(R.string.overview_goal_label, formatDurationHours(goalHours)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (progress != null) {
LinearProgressIndicator(progress = { progress }, color = tint, modifier = Modifier.fillMaxWidth().padding(top = 6.dp))
}
}
}
@Composable
private fun OverviewCard(title: String, content: @Composable () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
content()
}
}
}
@Composable
private fun LoadsCard(state: DetailState, m: SystemMetrics, onAddLoad: () -> Unit, onOpenLibrary: () -> Unit) {
OverviewCard(stringResource(R.string.loads_overview_header_title)) {
if (state.loads.isEmpty()) {
Text(stringResource(R.string.overview_loads_empty_title), fontWeight = FontWeight.Medium)
Text(stringResource(R.string.overview_loads_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onAddLoad) { Text(stringResource(R.string.loads_empty_create)) }
}
} else {
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
SummaryMetric(Icons.Outlined.ListAlt, state.loads.size.toString(), stringResource(R.string.loads_metric_count), MaterialTheme.colorScheme.primary)
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalCurrent)} A", stringResource(R.string.loads_metric_current), SysOrange)
SummaryMetric(Icons.Outlined.Speed, "${Fmt.number(m.totalPower)} W", stringResource(R.string.loads_metric_power), SysGreen)
}
}
}
}
@Composable
private fun BatteriesCard(state: DetailState, m: SystemMetrics, onAddBattery: () -> Unit) {
OverviewCard(stringResource(R.string.battery_bank_header_title)) {
if (state.batteries.isEmpty()) {
Text(stringResource(R.string.battery_empty_title), fontWeight = FontWeight.Medium)
Button(onClick = onAddBattery) { Text(stringResource(R.string.battery_empty_create)) }
} else {
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
SummaryMetric(Icons.Outlined.BatteryFull, state.batteries.size.toString(), stringResource(R.string.battery_metric_count), SysBlue)
SummaryMetric(Icons.Outlined.Speed, "${Fmt.number(m.totalCapacity)} Ah", stringResource(R.string.battery_metric_capacity), SysOrange)
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalUsableEnergy)} Wh", stringResource(R.string.battery_metric_usable_energy), SysGreen)
}
}
}
}
@Composable
private fun ChargersCard(state: DetailState, m: SystemMetrics, onAddCharger: () -> Unit) {
OverviewCard(stringResource(R.string.overview_chargers_header_title)) {
if (state.chargers.isEmpty()) {
Text(stringResource(R.string.overview_chargers_empty_title), fontWeight = FontWeight.Medium)
Text(stringResource(R.string.overview_chargers_empty_subtitle), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = onAddCharger) { Text(stringResource(R.string.overview_chargers_empty_create)) }
} else {
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(20.dp)) {
SummaryMetric(Icons.Outlined.Bolt, state.chargers.size.toString(), stringResource(R.string.chargers_metric_count), SysBlue)
SummaryMetric(Icons.Outlined.Speed, "${Fmt.number(m.totalChargerCurrent)} A", stringResource(R.string.chargers_metric_current), SysOrange)
SummaryMetric(Icons.Outlined.Bolt, "${Fmt.number(m.totalChargerPower)} W", stringResource(R.string.chargers_metric_power), SysGreen)
}
}
}
}

View File

@@ -0,0 +1,79 @@
package app.voltplan.cable.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.voltplan.cable.R
import app.voltplan.cable.data.UnitSystem
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.theme.SysOrange
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val settings = LocalUnitSettings.current
val unit by settings.unitSystem.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.settings_title)) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back)) }
},
)
},
) { padding ->
Column(
Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(stringResource(R.string.settings_units_section), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Row(Modifier.selectableGroup().fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = unit == UnitSystem.METRIC,
onClick = { settings.setUnitSystem(UnitSystem.METRIC) },
label = { Text(stringResource(R.string.units_metric_display)) },
)
FilterChip(
selected = unit == UnitSystem.IMPERIAL,
onClick = { settings.setUnitSystem(UnitSystem.IMPERIAL) },
label = { Text(stringResource(R.string.units_imperial_display)) },
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(Icons.Outlined.Warning, contentDescription = null, tint = SysOrange, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.settings_disclaimer_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
}
Text(stringResource(R.string.settings_disclaimer_body), style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.settings_disclaimer_points), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}

View File

@@ -0,0 +1,251 @@
package app.voltplan.cable.ui.system
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.BatteryFull
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.PictureAsPdf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.ui.LocalUnitSettings
import app.voltplan.cable.ui.batteries.BatteriesTab
import app.voltplan.cable.ui.chargers.ChargersTab
import app.voltplan.cable.ui.components.AppearanceEditorSheet
import app.voltplan.cable.ui.loads.ComponentsTab
import app.voltplan.cable.ui.overview.OverviewTab
import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.systemIconOptions
import app.voltplan.cable.ui.theme.componentColor
import app.voltplan.cable.pdf.SystemOverviewPdf
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import kotlinx.coroutines.launch
enum class ComponentTab(val analytics: String) {
OVERVIEW("overview"),
COMPONENTS("components"),
BATTERIES("batteries"),
CHARGERS("chargers"),
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SystemDetailScreen(
systemId: String,
onBack: () -> Unit,
onOpenLoad: (String) -> Unit,
onNewLoad: () -> Unit,
onEditBattery: (String) -> Unit,
onNewBattery: () -> Unit,
onEditCharger: (String) -> Unit,
onNewCharger: () -> Unit,
onOpenBom: () -> Unit,
onOpenLibrary: () -> Unit,
) {
val context = LocalContext.current
val app = context.applicationContext as CableApplication
val vm: SystemDetailViewModel = viewModel(
key = "system-$systemId",
factory = viewModelFactory { initializer { SystemDetailViewModel(app, systemId) } },
)
val state by vm.state.collectAsStateWithLifecycle()
val settings = LocalUnitSettings.current
val unitSystem by settings.unitSystem.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var tab by rememberSaveableTab()
var showSystemEditor by remember { mutableStateOf(false) }
var showOverviewMenu by remember { mutableStateOf(false) }
val system = state.system
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clip(RoundedCornerShape(8.dp)),
) {
if (system != null) {
Box(
Modifier.size(28.dp).clip(RoundedCornerShape(6.dp)).background(componentColor(system.colorName)),
contentAlignment = Alignment.Center,
) {
Icon(sfSymbol(system.iconName), contentDescription = null, tint = Color.White, modifier = Modifier.size(16.dp))
}
Text(
system.name,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 8.dp).clickable { showSystemEditor = true },
)
}
}
},
actions = {
when (tab) {
ComponentTab.OVERVIEW -> {
IconButton(onClick = { showOverviewMenu = true }) {
Icon(Icons.Outlined.PictureAsPdf, contentDescription = stringResource(R.string.overview_share_pdf))
}
DropdownMenu(expanded = showOverviewMenu, onDismissRequest = { showOverviewMenu = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.overview_share_pdf)) },
onClick = {
showOverviewMenu = false
scope.launch {
SystemOverviewPdf.exportAndShare(context, state, unitSystem)
}
},
)
}
}
ComponentTab.COMPONENTS -> IconButton(onClick = onNewLoad) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
ComponentTab.BATTERIES -> IconButton(onClick = onNewBattery) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
ComponentTab.CHARGERS -> IconButton(onClick = onNewCharger) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
}
},
)
},
bottomBar = {
NavigationBar {
NavTab(tab, ComponentTab.OVERVIEW, Icons.Outlined.Dashboard, stringResource(R.string.tab_overview)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.COMPONENTS, Icons.Outlined.Layers, stringResource(R.string.tab_components)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.BATTERIES, Icons.Outlined.BatteryFull, stringResource(R.string.tab_batteries)) { tab = it; vm.logTabChange(it.analytics) }
NavTab(tab, ComponentTab.CHARGERS, Icons.Outlined.Bolt, stringResource(R.string.tab_chargers)) { tab = it; vm.logTabChange(it.analytics) }
}
},
) { padding ->
Box(Modifier.padding(padding)) {
when (tab) {
ComponentTab.OVERVIEW -> OverviewTab(
state = state,
unitSystem = unitSystem,
onAddLoad = onNewLoad,
onAddBattery = onNewBattery,
onAddCharger = onNewCharger,
onOpenLibrary = onOpenLibrary,
onOpenBom = { vm.logBomOpened(); onOpenBom() },
onSetRuntimeGoal = vm::setRuntimeGoal,
onSetChargeGoal = vm::setChargeGoal,
)
ComponentTab.COMPONENTS -> ComponentsTab(
state = state,
unitSystem = unitSystem,
onOpenLoad = onOpenLoad,
onNewLoad = onNewLoad,
onOpenLibrary = onOpenLibrary,
onDeleteLoad = vm::deleteLoad,
)
ComponentTab.BATTERIES -> BatteriesTab(
state = state,
onEditBattery = onEditBattery,
onNewBattery = onNewBattery,
onDeleteBattery = vm::deleteBattery,
)
ComponentTab.CHARGERS -> ChargersTab(
state = state,
onEditCharger = onEditCharger,
onNewCharger = onNewCharger,
onDeleteCharger = vm::deleteCharger,
)
}
}
}
if (showSystemEditor && system != null) {
var location by remember { mutableStateOf(system.location) }
AppearanceEditorSheet(
title = stringResource(R.string.editor_system_title),
nameLabel = stringResource(R.string.editor_system_name),
previewSubtitle = location.ifBlank { stringResource(R.string.editor_system_location) },
icons = systemIconOptions,
initialName = system.name,
initialIcon = system.iconName,
initialColor = system.colorName,
extra = {
OutlinedTextField(
value = location,
onValueChange = { location = it },
label = { Text(stringResource(R.string.editor_system_location)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
onSave = { name, icon, color -> vm.saveSystem(name, location, icon, color) },
onDismiss = { showSystemEditor = false },
)
}
}
@Composable
private fun RowScope.NavTab(
current: ComponentTab,
tab: ComponentTab,
icon: ImageVector,
label: String,
onSelect: (ComponentTab) -> Unit,
) {
NavigationBarItem(
selected = current == tab,
onClick = { if (current != tab) onSelect(tab) },
icon = { Icon(icon, contentDescription = label) },
label = { Text(label) },
)
}
@Composable
private fun rememberSaveableTab() = remember { mutableStateOf(ComponentTab.OVERVIEW) }

View File

@@ -0,0 +1,88 @@
package app.voltplan.cable.ui.system
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.calc.SystemMetrics
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.SavedBattery
import app.voltplan.cable.data.model.SavedCharger
import app.voltplan.cable.data.model.SavedLoad
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class DetailState(
val system: ElectricalSystem? = null,
val loads: List<SavedLoad> = emptyList(),
val batteries: List<SavedBattery> = emptyList(),
val chargers: List<SavedCharger> = emptyList(),
) {
val metrics: SystemMetrics get() = SystemMetrics(loads, batteries, chargers)
}
class SystemDetailViewModel(
app: CableApplication,
val systemId: String,
) : ViewModel() {
private val repo = app.repository
val state: StateFlow<DetailState> = combine(
repo.observeSystem(systemId),
repo.observeLoads(systemId),
repo.observeBatteries(systemId),
repo.observeChargers(systemId),
) { system, loads, batteries, chargers ->
DetailState(system, loads, batteries, chargers)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), DetailState())
fun logTabChange(tab: String) {
Analytics.log("Tab Changed", mapOf("tab" to tab, "system" to (state.value.system?.name ?: "")))
}
fun saveSystem(name: String, location: String, iconName: String, colorName: String) {
val current = state.value.system ?: return
viewModelScope.launch {
repo.upsertSystem(current.copy(name = name, location = location, iconName = iconName, colorName = colorName))
Analytics.log("System Updated", mapOf("name" to name))
}
}
fun deleteLoad(load: SavedLoad) {
viewModelScope.launch {
Analytics.log("Load Deleted", mapOf("name" to load.name, "system" to (state.value.system?.name ?: "")))
repo.deleteLoad(load)
}
}
fun deleteBattery(battery: SavedBattery) {
viewModelScope.launch {
Analytics.log("Battery Deleted", mapOf("name" to battery.name, "system" to (state.value.system?.name ?: "")))
repo.deleteBattery(battery)
}
}
fun deleteCharger(charger: SavedCharger) {
viewModelScope.launch {
Analytics.log("Charger Deleted", mapOf("name" to charger.name, "system" to (state.value.system?.name ?: "")))
repo.deleteCharger(charger)
}
}
fun setRuntimeGoal(hours: Double?) {
val current = state.value.system ?: return
viewModelScope.launch { repo.upsertSystem(current.copy(targetRuntimeHours = hours)) }
}
fun setChargeGoal(hours: Double?) {
val current = state.value.system ?: return
viewModelScope.launch { repo.upsertSystem(current.copy(targetChargeTimeHours = hours)) }
}
fun logBomOpened() {
Analytics.log("Bill Of Materials Opened", mapOf("system" to (state.value.system?.name ?: "")))
}
}

View File

@@ -0,0 +1,51 @@
package app.voltplan.cable.ui.systems
import java.text.Normalizer
import java.util.Locale
/** Derives a system icon from its name by keyword. Mirrors `SystemsView.systemIconName(for:)`. */
object SystemIconMapper {
private val mappings: List<Pair<List<String>, String>> = listOf(
listOf("rv", "van", "camper", "motorhome", "coach") to "bus",
listOf("truck", "trailer", "rig") to "truck.box",
listOf("boat", "marine", "yacht", "sail") to "sailboat",
listOf("plane", "air", "flight") to "airplane",
listOf("ferry", "ship") to "ferry",
listOf("house", "home", "cabin", "cottage", "lodge") to "house",
listOf("building", "office", "warehouse", "factory", "facility") to "building",
listOf("camp", "tent", "outdoor") to "tent",
listOf("solar", "sun") to "sun.max",
listOf("battery", "storage") to "battery.100",
listOf("server", "data", "network", "rack") to "server.rack",
listOf("computer", "electronics", "lab", "tech") to "cpu",
listOf("gear", "mechanic", "machine", "workshop") to "gear",
listOf("tool", "maintenance", "repair", "shop") to "wrench.adjustable",
listOf("hammer", "carpentry") to "hammer",
listOf("light", "lighting", "lamp") to "lightbulb",
listOf("bolt", "power", "electric") to "bolt",
listOf("plug") to "powerplug",
listOf("engine", "generator", "motor") to "engine.combustion",
listOf("fuel", "diesel", "gas") to "fuelpump",
listOf("water", "pump", "tank") to "drop",
listOf("heat", "heater", "furnace") to "flame",
listOf("cold", "freeze", "cool") to "snowflake",
listOf("climate", "hvac", "temperature") to "thermometer",
)
private const val DEFAULT = "building.2"
fun iconFor(name: String): String {
val normalized = Normalizer.normalize(name, Normalizer.Form.NFD)
.replace(Regex("\\p{Mn}+"), "")
.lowercase(Locale.getDefault())
for ((keywords, icon) in mappings) {
if (keywords.any { normalized.contains(it) }) return icon
}
return DEFAULT
}
val colorOptions = listOf(
"blue", "green", "orange", "red", "purple", "yellow",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray",
)
}

View File

@@ -0,0 +1,204 @@
package app.voltplan.cable.ui.systems
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material.icons.outlined.AutoAwesome
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import app.voltplan.cable.R
import app.voltplan.cable.ui.sfSymbol
import app.voltplan.cable.ui.theme.componentColor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SystemsScreen(
onOpenSystem: (String) -> Unit,
onOpenSettings: () -> Unit,
onOpenLibrary: () -> Unit,
vm: SystemsViewModel = viewModel(),
) {
val summaries by vm.systems.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.systems_title)) },
navigationIcon = {
IconButton(onClick = { Analytics_settings(onOpenSettings) }) {
Icon(Icons.Outlined.Settings, contentDescription = stringResource(R.string.settings_title))
}
},
actions = {
IconButton(onClick = {
vm.logCreateNavigation()
vm.createSystem(null, source = "toolbar", randomColor = false, onCreated = onOpenSystem)
}) {
Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.action_add))
}
},
)
},
) { padding ->
if (summaries.isEmpty()) {
SystemsOnboarding(
modifier = Modifier.padding(padding),
onCreate = { name ->
vm.createSystem(name, source = "onboarding", randomColor = true, onCreated = onOpenSystem)
},
)
} else {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp),
) {
items(summaries, key = { it.system.id }) { summary ->
SystemRow(
summary = summary,
onClick = {
vm.logOpen(summary, "list")
onOpenSystem(summary.system.id)
},
onDelete = { vm.deleteSystem(summary) },
)
}
}
}
}
}
private fun Analytics_settings(onOpenSettings: () -> Unit) {
app.voltplan.cable.analytics.Analytics.log("Settings Opened")
onOpenSettings()
}
@Composable
private fun SystemRow(summary: SystemSummary, onClick: () -> Unit, onDelete: () -> Unit) {
val system = summary.system
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp)
.clip(RoundedCornerShape(18.dp)),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
onClick = onClick,
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
Modifier.size(44.dp).clip(RoundedCornerShape(10.dp)).background(componentColor(system.colorName)),
contentAlignment = Alignment.Center,
) {
Icon(sfSymbol(system.iconName), contentDescription = null, tint = Color.White, modifier = Modifier.size(24.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(system.name, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyLarge)
if (system.location.isNotEmpty()) {
Text(system.location, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
componentSummaryText(summary),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = stringResource(R.string.action_delete), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
Icon(Icons.Outlined.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun componentSummaryText(summary: SystemSummary): String {
if (summary.loadCount == 0) return stringResource(R.string.system_list_no_components)
val count = pluralStringResource(R.plurals.component_count, summary.loadCount, summary.loadCount)
return "$count${summaryPowerLabel(summary.totalPower)}"
}
@Composable
private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String) -> Unit) {
var name by rememberSaveable { mutableStateOf("") }
val defaultName = stringResource(R.string.default_system_name)
val effective = remember(name) { name }
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(Icons.Outlined.AutoAwesome, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(48.dp))
Spacer(Modifier.size(16.dp))
Text(stringResource(R.string.onboarding_systems_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.size(8.dp))
Text(
stringResource(R.string.onboarding_systems_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(24.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.onboarding_systems_field)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(16.dp))
Button(
onClick = { onCreate(effective.ifBlank { defaultName }) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.onboarding_systems_create), fontWeight = FontWeight.SemiBold)
}
}
}

View File

@@ -0,0 +1,75 @@
package app.voltplan.cable.ui.systems
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.ElectricalSystem
import app.voltplan.cable.data.model.SavedLoad
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class SystemSummary(
val system: ElectricalSystem,
val loadCount: Int,
val totalPower: Double,
)
class SystemsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = (app as CableApplication).repository
val systems: StateFlow<List<SystemSummary>> =
combine(repo.observeSystems(), repo.observeAllLoads()) { systems, loads ->
systems.map { system ->
val systemLoads = loads.filter { it.systemId == system.id }
SystemSummary(
system = system,
loadCount = systemLoads.size,
totalPower = systemLoads.sumOf { it.power },
)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
/** 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) {
viewModelScope.launch {
val base = preferredName?.trim().takeUnless { it.isNullOrEmpty() } ?: "New System"
val name = repo.uniqueSystemName(base)
val color = if (randomColor) SystemIconMapper.colorOptions.random() else "blue"
val system = ElectricalSystem(
name = name,
iconName = SystemIconMapper.iconFor(name),
colorName = color,
)
repo.upsertSystem(system)
Analytics.log("System Created", mapOf("name" to name, "source" to source))
onCreated(system.id)
}
}
fun logCreateNavigation() = Analytics.log("System Create Navigation")
fun logOpen(summary: SystemSummary, source: String) {
Analytics.log(
"System Opened",
mapOf("name" to summary.system.name, "source" to source, "loads" to summary.loadCount),
)
}
fun deleteSystem(summary: SystemSummary) {
viewModelScope.launch {
Analytics.log(
"System Deleted",
mapOf("name" to summary.system.name, "loads" to summary.loadCount),
)
repo.deleteSystem(summary.system)
}
}
}
fun summaryPowerLabel(totalPower: Double): String =
if (totalPower >= 1000) String.format("%.1fkW", totalPower / 1000) else String.format("%.0fW", totalPower)

View File

@@ -0,0 +1,47 @@
package app.voltplan.cable.ui.theme
import androidx.compose.ui.graphics.Color
// Brand accent — the teal used by the iOS StatsHeaderContainer (RGB 81/144/152).
val CableTeal = Color(0xFF519098)
val CableTealDark = Color(0xFF6FB3BC)
// Component palette — mirrors Color.componentColor(named:) in LoadIconView.swift.
// Approximates Apple's system colors so the Android app reads identically.
val SysBlue = Color(0xFF007AFF)
val SysGreen = Color(0xFF34C759)
val SysOrange = Color(0xFFFF9500)
val SysRed = Color(0xFFFF3B30)
val SysPurple = Color(0xFFAF52DE)
val SysYellow = Color(0xFFFFCC00)
val SysPink = Color(0xFFFF2D55)
val SysTeal = Color(0xFF30B0C7)
val SysIndigo = Color(0xFF5856D6)
val SysMint = Color(0xFF00C7BE)
val SysCyan = Color(0xFF32ADE6)
val SysBrown = Color(0xFFA2845E)
val SysGray = Color(0xFF8E8E93)
/** Maps a stored color name to a SwiftUI-equivalent color. Mirrors `Color.componentColor(named:)`. */
fun componentColor(named: String?): Color = when (named) {
"blue" -> SysBlue
"green" -> SysGreen
"orange" -> SysOrange
"red" -> SysRed
"purple" -> SysPurple
"yellow" -> SysYellow
"pink" -> SysPink
"teal" -> SysTeal
"indigo" -> SysIndigo
"mint" -> SysMint
"cyan" -> SysCyan
"brown" -> SysBrown
"gray" -> SysGray
else -> SysBlue
}
/** Ordered list of selectable component colors, matching ItemEditorView's curated palette. */
val curatedColorNames = listOf(
"blue", "green", "orange", "red", "purple", "yellow",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray",
)

View File

@@ -0,0 +1,46 @@
package app.voltplan.cable.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme(
primary = CableTeal,
secondary = SysBlue,
tertiary = SysOrange,
)
private val DarkColors = darkColorScheme(
primary = CableTealDark,
secondary = SysBlue,
tertiary = SysOrange,
)
@Composable
fun CableTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(
colorScheme = colorScheme,
typography = CableTypography,
content = content,
)
}

View File

@@ -0,0 +1,6 @@
package app.voltplan.cable.ui.theme
import androidx.compose.material3.Typography
// Default Material 3 typography is a close match for the iOS text styles used here.
val CableTypography = Typography()

View File

@@ -0,0 +1,47 @@
package app.voltplan.cable.util
import java.text.NumberFormat
import java.util.Locale
import kotlin.math.roundToInt
/**
* Locale-aware numeric helpers that mirror the iOS NumberFormatter usage
* (minimumFractionDigits = 0, maximumFractionDigits = 1).
*/
object Fmt {
private fun formatter(maxFraction: Int): NumberFormat =
NumberFormat.getNumberInstance(Locale.getDefault()).apply {
minimumFractionDigits = 0
maximumFractionDigits = maxFraction
}
/** Locale-aware, 01 fraction digits. Equivalent to the iOS display formatter. */
fun number(value: Double): String = formatter(1).format(value)
/** Locale-aware integer (0 fraction digits). */
fun integer(value: Double): String = formatter(0).format(value)
fun roundToTenth(value: Double): Double = (value * 10).roundToInt() / 10.0
fun roundToNearestFive(value: Double): Double = (value / 5).roundToInt() * 5.0
/** Parses user input accepting both "." and "," decimal separators. */
fun parseInput(text: String): Double? {
val trimmed = text.trim()
if (trimmed.isEmpty()) return null
trimmed.toDoubleOrNull()?.let { return it }
// Try swapping the locale separator the other way.
val swapped = if (trimmed.contains(',')) trimmed.replace(',', '.') else trimmed.replace('.', ',')
swapped.replace(',', '.').toDoubleOrNull()?.let { return it }
return try {
NumberFormat.getNumberInstance(Locale.getDefault()).parse(trimmed)?.toDouble()
} catch (_: Exception) {
null
}
}
/** Formats a recommended fuse rating: "%.0f" when whole, "%.1f" otherwise. */
fun fuse(value: Double): String =
if (value == value.roundToInt().toDouble()) value.roundToInt().toString()
else String.format(Locale.US, "%.1f", value)
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Simple lightning bolt mark on the adaptive foreground safe zone. -->
<path
android:fillColor="#FFFFFF"
android:pathData="M60,28 L42,58 L54,58 L48,80 L70,48 L57,48 Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="component_count">
<item quantity="one">%d Komponente</item>
<item quantity="other">%d Komponenten</item>
</plurals>
</resources>

View File

@@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cable</string>
<!-- Actions -->
<string name="action_add">Hinzufügen</string>
<string name="action_back">Zurück</string>
<string name="action_delete">Löschen</string>
<!-- Systems -->
<string name="systems_title">Systeme</string>
<string name="system_list_no_components">Noch keine Verbraucher</string>
<string name="default_system_name">Mein System</string>
<string name="onboarding_systems_title">Erstelle dein erstes System</string>
<string name="onboarding_systems_subtitle">Gib deinem Setup einen Namen, damit Cable by VoltPlan Verbraucher, Verkabelung und Empfehlungen an einem Ort organisieren kann.</string>
<string name="onboarding_systems_field">Systemname</string>
<string name="onboarding_systems_create">System erstellen</string>
<!-- Tabs -->
<string name="tab_overview">Übersicht</string>
<string name="tab_components">Verbraucher</string>
<string name="tab_batteries">Batterien</string>
<string name="tab_chargers">Ladegeräte</string>
<!-- System editor -->
<string name="editor_system_title">System bearbeiten</string>
<string name="editor_system_name">Name des Systems</string>
<string name="editor_system_location">Standort (optional)</string>
<!-- Loads / Components -->
<string name="loads_overview_header_title">Verbraucher</string>
<string name="loads_metric_count">Verbraucher</string>
<string name="loads_metric_current">Strom</string>
<string name="loads_metric_power">Leistung</string>
<string name="loads_metric_fuse">Sicherung</string>
<string name="loads_metric_cable">Schnitt</string>
<string name="loads_metric_length">Länge</string>
<string name="loads_library_button">Bibliothek</string>
<string name="loads_status_missing_banner">Konfiguration deiner Verbraucher abschließen</string>
<string name="loads_onboarding_title">Erstelle deinen ersten Verbraucher</string>
<string name="loads_onboarding_subtitle">Statte dein System mit Verbrauchern aus und lass Cable by VoltPlan die Kabel- und Sicherungsempfehlungen übernehmen.</string>
<string name="loads_empty_create">Verbraucher hinzufügen</string>
<string name="loads_empty_library">Bibliothek durchsuchen</string>
<!-- Load editor -->
<string name="editor_load_title">Verbraucher bearbeiten</string>
<string name="editor_load_name">Name des Verbrauchers</string>
<string name="editor_load_preview">Vorschau</string>
<!-- Calculator sliders -->
<string name="slider_voltage_title">Spannung</string>
<string name="slider_current_title">Strom</string>
<string name="slider_power_title">Leistung</string>
<string name="slider_length_title">Kabellänge (%s)</string>
<string name="slider_button_watt">Watt</string>
<string name="slider_button_ampere">Ampere</string>
<!-- Calculator advanced -->
<string name="calculator_advanced_section_title">Erweitert</string>
<string name="calculator_advanced_duty_title">Einschaltdauer</string>
<string name="calculator_advanced_duty_helper">Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.</string>
<string name="calculator_advanced_usage_title">Tägliche Laufzeit</string>
<string name="calculator_advanced_usage_helper">Stunden pro Tag, in denen die Last eingeschaltet ist.</string>
<string name="calculator_advanced_usage_unit">Std./Tag</string>
<string name="calculator_alert_duty_title">Einschaltdauer bearbeiten</string>
<string name="calculator_alert_duty_message">Einschaltdauer als Prozent (0-100%) eingeben.</string>
<string name="calculator_alert_usage_title">Tägliche Laufzeit bearbeiten</string>
<string name="calculator_alert_usage_message">Stunden pro Tag eingeben, in denen die Last aktiv ist.</string>
<!-- Affiliate / BOM button -->
<string name="affiliate_button_review_parts">Bauteile prüfen</string>
<string name="affiliate_description_with_link">Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen.</string>
<string name="affiliate_description_without_link">Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen.</string>
<string name="affiliate_disclaimer">Käufe über Affiliate-Links können VoltPlan unterstützen.</string>
<!-- Batteries -->
<string name="battery_bank_header_title">Batterien</string>
<string name="battery_metric_count">Batterien</string>
<string name="battery_metric_capacity">Kapazität</string>
<string name="battery_metric_usable_capacity">Nutzbare Kapazität</string>
<string name="battery_metric_usable_energy">Nutzbare Energie</string>
<string name="battery_badge_voltage">Spannung</string>
<string name="battery_badge_energy">Energie</string>
<string name="battery_banner_voltage">Spannungsabweichung erkannt</string>
<string name="battery_banner_capacity">Kapazitätsabweichung erkannt</string>
<string name="battery_empty_title">Noch keine Batterien</string>
<string name="battery_empty_create">Batterie hinzufügen</string>
<string name="battery_onboarding_title">Füge deine erste Batterie hinzu</string>
<string name="battery_onboarding_subtitle">Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten.</string>
<!-- Battery editor -->
<string name="battery_field_name">Name</string>
<string name="battery_field_chemistry">Chemie</string>
<string name="battery_section_advanced">Erweitert</string>
<string name="battery_slider_voltage">Nennspannung</string>
<string name="battery_slider_capacity">Kapazität</string>
<string name="battery_slider_usable_capacity">Nutzbare Kapazität (%)</string>
<string name="battery_slider_charge_voltage">Ladespannung</string>
<string name="battery_slider_cutoff_voltage">Abschaltspannung</string>
<string name="battery_slider_temperature_range">Temperaturbereich</string>
<string name="battery_temp_min">Minimum</string>
<string name="battery_temp_max">Maximum</string>
<string name="battery_button_reset_default">Zurücksetzen</string>
<string name="battery_charge_helper">Lege die maximal empfohlene Ladespannung fest.</string>
<string name="battery_cutoff_helper">Lege die minimale sichere Entladespannung fest.</string>
<string name="battery_temp_helper">Definiere den empfohlenen Betriebstemperaturbereich.</string>
<string name="battery_usable_footer_default">Standard %s basierend auf der Chemie.</string>
<string name="battery_usable_footer_override">Überschreibung aktiv. Chemie-Standard bleibt %s.</string>
<string name="battery_alert_voltage_title">Nennspannung bearbeiten</string>
<string name="battery_alert_capacity_title">Kapazität bearbeiten</string>
<string name="battery_alert_usable_title">Nutzbare Kapazität bearbeiten</string>
<string name="battery_alert_charge_title">Ladespannung bearbeiten</string>
<string name="battery_alert_cutoff_title">Abschaltspannung bearbeiten</string>
<string name="battery_alert_min_temp_title">Mindesttemperatur bearbeiten</string>
<string name="battery_alert_max_temp_title">Höchsttemperatur bearbeiten</string>
<string name="battery_appearance_title">Batterie-Darstellung</string>
<string name="battery_appearance_subtitle">Passe an, wie diese Batterie angezeigt wird</string>
<!-- Chargers -->
<string name="chargers_summary_title">Ladeübersicht</string>
<string name="chargers_metric_count">Ladegeräte</string>
<string name="chargers_metric_output">Spannung</string>
<string name="chargers_metric_current">Ladestrom</string>
<string name="chargers_metric_power">Ladeleistung</string>
<string name="chargers_badge_input">Eingang</string>
<string name="chargers_badge_output">Ausgang</string>
<string name="chargers_badge_current">Strom</string>
<string name="chargers_badge_power">Leistung</string>
<string name="chargers_onboarding_title">Füge deine Ladegeräte hinzu</string>
<string name="chargers_onboarding_subtitle">Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten.</string>
<string name="chargers_onboarding_primary">Ladegerät erstellen</string>
<!-- Charger editor -->
<string name="charger_field_name">Name</string>
<string name="charger_field_input_voltage">Eingangsspannung</string>
<string name="charger_field_output_voltage">Ausgangsspannung</string>
<string name="charger_field_current">Ladestrom</string>
<string name="charger_field_power">Ladeleistung</string>
<string name="charger_field_power_footer">Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom.</string>
<string name="charger_source_type">Stromquelle</string>
<string name="charger_source_shore">Landstrom</string>
<string name="charger_source_solar">Solar</string>
<string name="charger_source_wind">Wind</string>
<string name="charger_source_generator">Generator</string>
<string name="charger_source_alternator">Lichtmaschine</string>
<string name="charger_alert_input_voltage_title">Eingangsspannung bearbeiten</string>
<string name="charger_alert_output_voltage_title">Ausgangsspannung bearbeiten</string>
<string name="charger_alert_current_title">Ladestrom bearbeiten</string>
<string name="charger_alert_power_title">Ladeleistung bearbeiten</string>
<string name="charger_alert_voltage_message">Spannung in Volt (V) eingeben</string>
<string name="charger_alert_current_message">Strom in Ampere (A) eingeben</string>
<string name="charger_alert_power_message">Leistung in Watt (W) eingeben</string>
<string name="charger_appearance_title">Ladegerät-Darstellung</string>
<string name="charger_appearance_subtitle">Passe an, wie dieses Ladegerät angezeigt wird</string>
<!-- Overview -->
<string name="overview_system_header_title">Systemübersicht</string>
<string name="overview_runtime_title">Geschätzte Laufzeit</string>
<string name="overview_runtime_subtitle">Bei dauerhafter Vollast</string>
<string name="overview_runtime_placeholder">Kapazität hinzufügen</string>
<string name="overview_runtime_goal_title">Laufzeit-Ziel</string>
<string name="overview_chargetime_title">Geschätzte Ladezeit</string>
<string name="overview_chargetime_subtitle">Bei kombinierter Laderate</string>
<string name="overview_chargetime_placeholder">Ladegeräte hinzufügen</string>
<string name="overview_chargetime_goal_title">Ladezeit-Ziel</string>
<string name="overview_bom_title">Stückliste</string>
<string name="overview_bom_subtitle">Tippe, um Komponenten zu prüfen</string>
<string name="overview_bom_placeholder">Verbraucher hinzufügen</string>
<string name="overview_goal_label">Ziel %s</string>
<string name="overview_goal_clear">Ziel entfernen</string>
<string name="overview_goal_cancel">Abbrechen</string>
<string name="overview_goal_save">Speichern</string>
<string name="overview_loads_empty_title">Noch keine Verbraucher eingerichtet</string>
<string name="overview_loads_empty_subtitle">Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten.</string>
<string name="overview_chargers_header_title">Ladegeräte</string>
<string name="overview_chargers_empty_title">Noch keine Ladegeräte konfiguriert</string>
<string name="overview_chargers_empty_subtitle">Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen.</string>
<string name="overview_chargers_empty_create">Ladegerät hinzufügen</string>
<string name="overview_share_pdf">Vollständiger Bericht (PDF)</string>
<!-- Goal editor steppers -->
<string name="goal_days">Tage</string>
<string name="goal_hours">Stunden</string>
<string name="goal_minutes">Minuten</string>
<!-- Bill of Materials -->
<string name="bom_navigation_title">Stückliste</string>
<string name="bom_empty_message">Dieses System hat noch keine Komponenten.</string>
<string name="bom_export_pdf_button">PDF exportieren</string>
<string name="bom_item_cable_red">Stromkabel (rot)</string>
<string name="bom_item_cable_black">Stromkabel (schwarz)</string>
<string name="bom_item_fuse">Sicherung &amp; Halter</string>
<string name="bom_item_terminals">Kabelschuhe / Klemmen</string>
<string name="bom_fuse_detail">Inline-Halter und %dA-Sicherung</string>
<string name="bom_terminals_detail">Ring- oder Gabelkabelschuhe für %s-Leitungen</string>
<string name="bom_category_components_title">Komponenten &amp; Ladegeräte</string>
<string name="bom_category_components_subtitle">Hauptverbraucher, Regler und Ladehardware.</string>
<string name="bom_category_batteries_title">Batterien</string>
<string name="bom_category_batteries_subtitle">Hausspeicher und Batteriebänke.</string>
<string name="bom_category_cables_title">Kabel</string>
<string name="bom_category_cables_subtitle">Passende Leitungen für jede Strecke.</string>
<string name="bom_category_fuses_title">Sicherungen</string>
<string name="bom_category_fuses_subtitle">Stromkreisschutz und Halter.</string>
<string name="bom_category_accessories_title">Zubehör</string>
<string name="bom_category_accessories_subtitle">Sicherungen, Kabelschuhe und weiteres Montagematerial.</string>
<string name="bom_search_device_fallback">DC Gerät %1$.0fW %2$.0fV</string>
<string name="bom_search_cable_red">%s rotes Batteriekabel</string>
<string name="bom_search_cable_black">%s schwarzes Batteriekabel</string>
<string name="bom_search_fuse">KFZ Sicherungshalter %dA</string>
<string name="bom_search_terminals">%s Kabelschuhe</string>
<string name="bom_search_battery">%1$dAh %2$dV %3$s Batterie</string>
<string name="bom_search_charger">%1$dV %2$dA Batterieladegerät</string>
<string name="bom_pdf_header_title">System-Stückliste</string>
<string name="bom_pdf_placeholder_empty">Keine Komponenten verfügbar.</string>
<!-- Overview PDF -->
<string name="overview_pdf_summary_title">Systemübersicht</string>
<string name="overview_pdf_summary_runtime">Geschätzte Laufzeit</string>
<string name="overview_pdf_summary_chargetime">Ladezeit</string>
<string name="overview_pdf_summary_totalpower">Gesamtleistung</string>
<string name="overview_pdf_summary_totalcurrent">Gesamtstrom</string>
<string name="overview_pdf_summary_batterycapacity">Batteriekapazität</string>
<string name="overview_pdf_summary_chargerpower">Ladeleistung</string>
<string name="overview_pdf_loads_section">Verbraucher</string>
<string name="overview_pdf_batteries_section">Batterien</string>
<string name="overview_pdf_chargers_section">Ladegeräte</string>
<string name="overview_pdf_load_voltage">Spannung</string>
<string name="overview_pdf_load_current">Strom</string>
<string name="overview_pdf_load_power">Leistung</string>
<string name="overview_pdf_load_cable">Kabelquerschnitt</string>
<string name="overview_pdf_load_vdrop">Spannungsabfall</string>
<string name="overview_pdf_load_fuse">Sicherung</string>
<string name="overview_pdf_battery_chemistry">Chemie</string>
<string name="overview_pdf_battery_voltage">Spannung</string>
<string name="overview_pdf_battery_capacity">Kapazität</string>
<string name="overview_pdf_battery_usable">Nutzbare Kapazität</string>
<string name="overview_pdf_battery_energy">Energie</string>
<string name="overview_pdf_charger_input">Eingangsspannung</string>
<string name="overview_pdf_charger_output">Ausgangsspannung</string>
<string name="overview_pdf_charger_current">Max. Strom</string>
<string name="overview_pdf_charger_power">Leistung</string>
<!-- Component Library -->
<string name="library_title">VoltPlan-Bibliothek</string>
<string name="library_search_placeholder">Komponenten suchen</string>
<string name="library_error_title">Komponenten konnten nicht geladen werden</string>
<string name="library_retry">Erneut versuchen</string>
<string name="library_empty_title">Keine Komponenten verfügbar</string>
<string name="library_empty_subtitle">Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.</string>
<string name="library_details_coming">Details folgen in Kürze</string>
<!-- Settings -->
<string name="settings_title">Einstellungen</string>
<string name="settings_units_section">Einheiten</string>
<string name="units_metric_display">Metrisch (mm², m)</string>
<string name="units_imperial_display">Imperial (AWG, ft)</string>
<string name="settings_disclaimer_title">Sicherheitshinweis</string>
<string name="settings_disclaimer_body">Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.</string>
<string name="settings_disclaimer_points">• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu\n• Beachte alle örtlichen Vorschriften und Normen\n• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden\n• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren\n• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen</string>
<!-- Misc -->
<string name="component_fallback_name">Komponente</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="component_count">
<item quantity="one">%d componente</item>
<item quantity="other">%d componentes</item>
</plurals>
</resources>

View File

@@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cable</string>
<!-- Actions -->
<string name="action_add">Añadir</string>
<string name="action_back">Atrás</string>
<string name="action_delete">Eliminar</string>
<!-- Systems -->
<string name="systems_title">Sistemas</string>
<string name="system_list_no_components">Aún no hay componentes</string>
<string name="default_system_name">Mi sistema</string>
<string name="onboarding_systems_title">Crea tu primer sistema</string>
<string name="onboarding_systems_subtitle">Ponle un nombre a tu sistema para que Cable by VoltPlan organice cargas, cableado y recomendaciones en un solo lugar.</string>
<string name="onboarding_systems_field">Nombre del sistema</string>
<string name="onboarding_systems_create">Crear sistema</string>
<!-- Tabs -->
<string name="tab_overview">Resumen</string>
<string name="tab_components">Componentes</string>
<string name="tab_batteries">Baterías</string>
<string name="tab_chargers">Cargadores</string>
<!-- System editor -->
<string name="editor_system_title">Editar sistema</string>
<string name="editor_system_name">Nombre del sistema</string>
<string name="editor_system_location">Ubicación (opcional)</string>
<!-- Loads / Components -->
<string name="loads_overview_header_title">Resumen de cargas</string>
<string name="loads_metric_count">Cargas</string>
<string name="loads_metric_current">Corriente total</string>
<string name="loads_metric_power">Potencia total</string>
<string name="loads_metric_fuse">Fusible</string>
<string name="loads_metric_cable">Cable</string>
<string name="loads_metric_length">Longitud</string>
<string name="loads_library_button">Biblioteca</string>
<string name="loads_status_missing_banner">Completa la configuración de tus cargas</string>
<string name="loads_onboarding_title">Añade tu primer componente</string>
<string name="loads_onboarding_subtitle">Da vida a tu sistema con componentes y deja que Cable by VoltPlan se encargue de recomendar cables y fusibles.</string>
<string name="loads_empty_create">Añadir carga</string>
<string name="loads_empty_library">Explorar biblioteca</string>
<!-- Load editor -->
<string name="editor_load_title">Editar carga</string>
<string name="editor_load_name">Nombre de la carga</string>
<string name="editor_load_preview">Vista previa</string>
<!-- Calculator sliders -->
<string name="slider_voltage_title">Voltaje</string>
<string name="slider_current_title">Corriente</string>
<string name="slider_power_title">Potencia</string>
<string name="slider_length_title">Longitud del cable (%s)</string>
<string name="slider_button_watt">Vatios</string>
<string name="slider_button_ampere">Amperios</string>
<!-- Calculator advanced -->
<string name="calculator_advanced_section_title">Configuración avanzada</string>
<string name="calculator_advanced_duty_title">Ciclo de trabajo</string>
<string name="calculator_advanced_duty_helper">Porcentaje del tiempo activo en el que la carga consume energía.</string>
<string name="calculator_advanced_usage_title">Tiempo encendido diario</string>
<string name="calculator_advanced_usage_helper">Horas por día que la carga permanece encendida.</string>
<string name="calculator_advanced_usage_unit">h/día</string>
<string name="calculator_alert_duty_title">Editar ciclo de trabajo</string>
<string name="calculator_alert_duty_message">Introduce el porcentaje de ciclo de trabajo (0-100%).</string>
<string name="calculator_alert_usage_title">Editar tiempo encendido diario</string>
<string name="calculator_alert_usage_message">Introduce las horas por día que la carga está activa.</string>
<!-- Affiliate / BOM button -->
<string name="affiliate_button_review_parts">Revisar componentes</string>
<string name="affiliate_description_with_link">Al tocar verás una lista completa de materiales antes de abrir el enlace de afiliado. Las compras pueden apoyar a VoltPlan.</string>
<string name="affiliate_description_without_link">Al tocar verás una lista completa de materiales con búsquedas de compra para ayudarte a conseguir piezas.</string>
<string name="affiliate_disclaimer">Las compras a través de enlaces de afiliados pueden apoyar a VoltPlan.</string>
<!-- Batteries -->
<string name="battery_bank_header_title">Banco de baterías</string>
<string name="battery_metric_count">Baterías</string>
<string name="battery_metric_capacity">Capacidad</string>
<string name="battery_metric_usable_capacity">Capacidad utilizable</string>
<string name="battery_metric_usable_energy">Energía utilizable</string>
<string name="battery_badge_voltage">Voltaje</string>
<string name="battery_badge_energy">Energía</string>
<string name="battery_banner_voltage">Se detectó un desajuste de voltaje</string>
<string name="battery_banner_capacity">Se detectó un desajuste de capacidad</string>
<string name="battery_empty_title">Sin baterías todavía</string>
<string name="battery_empty_create">Añadir batería</string>
<string name="battery_onboarding_title">Añade tu primera batería</string>
<string name="battery_onboarding_subtitle">Controla la capacidad y la química del banco para mantener tus tiempos de autonomía bajo control.</string>
<!-- Battery editor -->
<string name="battery_field_name">Nombre</string>
<string name="battery_field_chemistry">Química</string>
<string name="battery_section_advanced">Avanzado</string>
<string name="battery_slider_voltage">Voltaje nominal</string>
<string name="battery_slider_capacity">Capacidad</string>
<string name="battery_slider_usable_capacity">Capacidad utilizable (%)</string>
<string name="battery_slider_charge_voltage">Voltaje de carga</string>
<string name="battery_slider_cutoff_voltage">Voltaje de corte</string>
<string name="battery_slider_temperature_range">Rango de temperatura</string>
<string name="battery_temp_min">Mínimo</string>
<string name="battery_temp_max">Máximo</string>
<string name="battery_button_reset_default">Restablecer</string>
<string name="battery_charge_helper">Establece el voltaje máximo de carga recomendado.</string>
<string name="battery_cutoff_helper">Establece el voltaje mínimo seguro de descarga.</string>
<string name="battery_temp_helper">Define el rango de temperatura de operación recomendado.</string>
<string name="battery_usable_footer_default">Predeterminado %s según la química.</string>
<string name="battery_usable_footer_override">Sobrescritura activa. El valor predeterminado por química sigue siendo %s.</string>
<string name="battery_alert_voltage_title">Editar voltaje nominal</string>
<string name="battery_alert_capacity_title">Editar capacidad</string>
<string name="battery_alert_usable_title">Editar capacidad utilizable</string>
<string name="battery_alert_charge_title">Editar voltaje de carga</string>
<string name="battery_alert_cutoff_title">Editar voltaje de corte</string>
<string name="battery_alert_min_temp_title">Editar temperatura mínima</string>
<string name="battery_alert_max_temp_title">Editar temperatura máxima</string>
<string name="battery_appearance_title">Apariencia de la batería</string>
<string name="battery_appearance_subtitle">Personaliza cómo se muestra esta batería</string>
<!-- Chargers -->
<string name="chargers_summary_title">Resumen de carga</string>
<string name="chargers_metric_count">Cargadores</string>
<string name="chargers_metric_output">Voltaje de salida</string>
<string name="chargers_metric_current">Tasa de carga</string>
<string name="chargers_metric_power">Potencia de carga</string>
<string name="chargers_badge_input">Entrada</string>
<string name="chargers_badge_output">Salida</string>
<string name="chargers_badge_current">Corriente</string>
<string name="chargers_badge_power">Potencia</string>
<string name="chargers_onboarding_title">Añade tus cargadores</string>
<string name="chargers_onboarding_subtitle">Lleva el control de los cargadores de costa, los cargadores de alternador y los controladores solares para conocer tu capacidad de carga.</string>
<string name="chargers_onboarding_primary">Crear cargador</string>
<!-- Charger editor -->
<string name="charger_field_name">Nombre</string>
<string name="charger_field_input_voltage">Voltaje de entrada</string>
<string name="charger_field_output_voltage">Voltaje de salida</string>
<string name="charger_field_current">Corriente de carga</string>
<string name="charger_field_power">Potencia de carga</string>
<string name="charger_field_power_footer">Déjalo en blanco si no se publica la potencia nominal. La calcularemos a partir del voltaje y la corriente.</string>
<string name="charger_source_type">Fuente de energía</string>
<string name="charger_source_shore">Corriente de tierra</string>
<string name="charger_source_solar">Solar</string>
<string name="charger_source_wind">Eólica</string>
<string name="charger_source_generator">Generador</string>
<string name="charger_source_alternator">Alternador</string>
<string name="charger_alert_input_voltage_title">Editar voltaje de entrada</string>
<string name="charger_alert_output_voltage_title">Editar voltaje de salida</string>
<string name="charger_alert_current_title">Editar corriente de carga</string>
<string name="charger_alert_power_title">Editar potencia de carga</string>
<string name="charger_alert_voltage_message">Introduce el voltaje en voltios (V)</string>
<string name="charger_alert_current_message">Introduce la corriente en amperios (A)</string>
<string name="charger_alert_power_message">Introduce la potencia en vatios (W)</string>
<string name="charger_appearance_title">Apariencia del cargador</string>
<string name="charger_appearance_subtitle">Personaliza cómo se muestra este cargador</string>
<!-- Overview -->
<string name="overview_system_header_title">Resumen del sistema</string>
<string name="overview_runtime_title">Autonomía estimada</string>
<string name="overview_runtime_subtitle">Con la carga máxima</string>
<string name="overview_runtime_placeholder">Añadir capacidad</string>
<string name="overview_runtime_goal_title">Objetivo de autonomía</string>
<string name="overview_chargetime_title">Tiempo de carga estimado</string>
<string name="overview_chargetime_subtitle">Con la tasa de carga combinada</string>
<string name="overview_chargetime_placeholder">Añadir cargadores</string>
<string name="overview_chargetime_goal_title">Objetivo de carga</string>
<string name="overview_bom_title">Lista de materiales</string>
<string name="overview_bom_subtitle">Pulsa para revisar los componentes</string>
<string name="overview_bom_placeholder">Añadir cargas</string>
<string name="overview_goal_label">Objetivo %s</string>
<string name="overview_goal_clear">Eliminar objetivo</string>
<string name="overview_goal_cancel">Cancelar</string>
<string name="overview_goal_save">Guardar</string>
<string name="overview_loads_empty_title">Aún no hay cargas configuradas</string>
<string name="overview_loads_empty_subtitle">Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema.</string>
<string name="overview_chargers_header_title">Resumen de cargadores</string>
<string name="overview_chargers_empty_title">Aún no hay cargadores configurados</string>
<string name="overview_chargers_empty_subtitle">Añade cargadores de toma de puerto, DC-DC o solares para conocer tu capacidad de carga.</string>
<string name="overview_chargers_empty_create">Añadir cargador</string>
<string name="overview_share_pdf">Informe completo (PDF)</string>
<!-- Goal editor steppers -->
<string name="goal_days">Días</string>
<string name="goal_hours">Horas</string>
<string name="goal_minutes">Minutos</string>
<!-- Bill of Materials -->
<string name="bom_navigation_title">Lista de materiales</string>
<string name="bom_empty_message">Todavía no hay componentes guardados en este sistema.</string>
<string name="bom_export_pdf_button">Exportar PDF</string>
<string name="bom_item_cable_red">Cable de alimentación (rojo)</string>
<string name="bom_item_cable_black">Cable de alimentación (negro)</string>
<string name="bom_item_fuse">Fusible y portafusibles</string>
<string name="bom_item_terminals">Terminales / zapatas</string>
<string name="bom_fuse_detail">Portafusibles en línea y fusible de %dA</string>
<string name="bom_terminals_detail">Terminales de anillo o de horquilla para cables de %s</string>
<string name="bom_category_components_title">Componentes y cargadores</string>
<string name="bom_category_components_subtitle">Dispositivos principales, controladores y equipos de carga.</string>
<string name="bom_category_batteries_title">Baterías</string>
<string name="bom_category_batteries_subtitle">Bancos domésticos y almacenamiento.</string>
<string name="bom_category_cables_title">Cables</string>
<string name="bom_category_cables_subtitle">Tendidos dimensionados para cada circuito.</string>
<string name="bom_category_fuses_title">Fusibles</string>
<string name="bom_category_fuses_subtitle">Protección de circuitos y portafusibles.</string>
<string name="bom_category_accessories_title">Accesorios</string>
<string name="bom_category_accessories_subtitle">Fusibles, terminales y piezas de soporte.</string>
<string name="bom_search_device_fallback">dispositivo DC %1$.0fW %2$.0fV</string>
<string name="bom_search_cable_red">%s cable batería rojo</string>
<string name="bom_search_cable_black">%s cable batería negro</string>
<string name="bom_search_fuse">portafusible en línea %dA</string>
<string name="bom_search_terminals">%s terminales de cable</string>
<string name="bom_search_battery">%1$dAh %2$dV %3$s batería</string>
<string name="bom_search_charger">%1$dV %2$dA cargador de batería</string>
<string name="bom_pdf_header_title">Lista de materiales del sistema</string>
<string name="bom_pdf_placeholder_empty">No hay componentes disponibles.</string>
<!-- Overview PDF -->
<string name="overview_pdf_summary_title">Resumen del sistema</string>
<string name="overview_pdf_summary_runtime">Autonomía estimada</string>
<string name="overview_pdf_summary_chargetime">Tiempo de carga</string>
<string name="overview_pdf_summary_totalpower">Potencia total</string>
<string name="overview_pdf_summary_totalcurrent">Corriente total</string>
<string name="overview_pdf_summary_batterycapacity">Capacidad de batería</string>
<string name="overview_pdf_summary_chargerpower">Potencia de carga</string>
<string name="overview_pdf_loads_section">Cargas</string>
<string name="overview_pdf_batteries_section">Baterías</string>
<string name="overview_pdf_chargers_section">Cargadores</string>
<string name="overview_pdf_load_voltage">Tensión</string>
<string name="overview_pdf_load_current">Corriente</string>
<string name="overview_pdf_load_power">Potencia</string>
<string name="overview_pdf_load_cable">Sección del cable</string>
<string name="overview_pdf_load_vdrop">Caída de tensión</string>
<string name="overview_pdf_load_fuse">Fusible</string>
<string name="overview_pdf_battery_chemistry">Química</string>
<string name="overview_pdf_battery_voltage">Tensión</string>
<string name="overview_pdf_battery_capacity">Capacidad</string>
<string name="overview_pdf_battery_usable">Capacidad utilizable</string>
<string name="overview_pdf_battery_energy">Energía</string>
<string name="overview_pdf_charger_input">Tensión de entrada</string>
<string name="overview_pdf_charger_output">Tensión de salida</string>
<string name="overview_pdf_charger_current">Corriente máx.</string>
<string name="overview_pdf_charger_power">Potencia</string>
<!-- Component Library -->
<string name="library_title">Biblioteca de VoltPlan</string>
<string name="library_search_placeholder">Buscar componentes</string>
<string name="library_error_title">No se pudieron cargar los componentes</string>
<string name="library_retry">Reintentar</string>
<string name="library_empty_title">No hay componentes disponibles</string>
<string name="library_empty_subtitle">Vuelve pronto para encontrar nuevas cargas de VoltPlan.</string>
<string name="library_details_coming">Detalles próximamente</string>
<!-- Settings -->
<string name="settings_title">Ajustes</string>
<string name="settings_units_section">Unidades</string>
<string name="units_metric_display">Métrico (mm², m)</string>
<string name="units_imperial_display">Imperial (AWG, ft)</string>
<string name="settings_disclaimer_title">Aviso de seguridad</string>
<string name="settings_disclaimer_body">Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.</string>
<string name="settings_disclaimer_points">• Consulta siempre a electricistas calificados para las instalaciones reales\n• Cumple todas las normativas y códigos eléctricos locales\n• Los trabajos eléctricos solo deben realizarlos profesionales autorizados\n• Estos cálculos pueden no tener en cuenta todos los factores ambientales\n• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas</string>
<!-- Misc -->
<string name="component_fallback_name">Componente</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="component_count">
<item quantity="one">%d composant</item>
<item quantity="other">%d composants</item>
</plurals>
</resources>

View File

@@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cable</string>
<!-- Actions -->
<string name="action_add">Ajouter</string>
<string name="action_back">Retour</string>
<string name="action_delete">Supprimer</string>
<!-- Systems -->
<string name="systems_title">Systèmes</string>
<string name="system_list_no_components">Aucun composant pour l\'instant</string>
<string name="default_system_name">Mon système</string>
<string name="onboarding_systems_title">Créez votre premier système</string>
<string name="onboarding_systems_subtitle">Donnez un nom à votre installation pour que Cable by VoltPlan organise charges, câblage et recommandations au même endroit.</string>
<string name="onboarding_systems_field">Nom du système</string>
<string name="onboarding_systems_create">Créer un système</string>
<!-- Tabs -->
<string name="tab_overview">Aperçu</string>
<string name="tab_components">Composants</string>
<string name="tab_batteries">Batteries</string>
<string name="tab_chargers">Chargeurs</string>
<!-- System editor -->
<string name="editor_system_title">Modifier le système</string>
<string name="editor_system_name">Nom du système</string>
<string name="editor_system_location">Emplacement (optionnel)</string>
<!-- Loads / Components -->
<string name="loads_overview_header_title">Aperçu des charges</string>
<string name="loads_metric_count">Charges</string>
<string name="loads_metric_current">Courant total</string>
<string name="loads_metric_power">Puissance totale</string>
<string name="loads_metric_fuse">Fusible</string>
<string name="loads_metric_cable">Câble</string>
<string name="loads_metric_length">Longueur</string>
<string name="loads_library_button">Bibliothèque</string>
<string name="loads_status_missing_banner">Terminez la configuration de vos charges</string>
<string name="loads_onboarding_title">Ajoutez votre premier composant</string>
<string name="loads_onboarding_subtitle">Donnez vie à votre système avec des composants et laissez Cable by VoltPlan recommander câbles et fusibles.</string>
<string name="loads_empty_create">Ajouter une charge</string>
<string name="loads_empty_library">Parcourir la bibliothèque</string>
<!-- Load editor -->
<string name="editor_load_title">Modifier la charge</string>
<string name="editor_load_name">Nom de la charge</string>
<string name="editor_load_preview">Aperçu</string>
<!-- Calculator sliders -->
<string name="slider_voltage_title">Tension</string>
<string name="slider_current_title">Courant</string>
<string name="slider_power_title">Puissance</string>
<string name="slider_length_title">Longueur du câble (%s)</string>
<string name="slider_button_watt">Watts</string>
<string name="slider_button_ampere">Ampères</string>
<!-- Calculator advanced -->
<string name="calculator_advanced_section_title">Paramètres avancés</string>
<string name="calculator_advanced_duty_title">Facteur de marche</string>
<string name="calculator_advanced_duty_helper">Pourcentage du temps actif pendant lequel la charge consomme réellement de l\'énergie.</string>
<string name="calculator_advanced_usage_title">Temps de fonctionnement quotidien</string>
<string name="calculator_advanced_usage_helper">Heures par jour pendant lesquelles la charge est allumée.</string>
<string name="calculator_advanced_usage_unit">h/jour</string>
<string name="calculator_alert_duty_title">Modifier le facteur de marche</string>
<string name="calculator_alert_duty_message">Saisissez le facteur de marche en pourcentage (0-100%).</string>
<string name="calculator_alert_usage_title">Modifier le temps de fonctionnement quotidien</string>
<string name="calculator_alert_usage_message">Saisissez le nombre d\'heures par jour pendant lesquelles la charge est active.</string>
<!-- Affiliate / BOM button -->
<string name="affiliate_button_review_parts">Examiner les composants</string>
<string name="affiliate_description_with_link">En appuyant, vous verrez une liste complète de matériel avant d\'ouvrir le lien d\'affiliation. Les achats peuvent soutenir VoltPlan.</string>
<string name="affiliate_description_without_link">En appuyant, vous verrez une liste complète de matériel avec des recherches d\'achat pour vous aider à trouver les pièces.</string>
<string name="affiliate_disclaimer">Les achats effectués via des liens d\'affiliation peuvent soutenir VoltPlan.</string>
<!-- Batteries -->
<string name="battery_bank_header_title">Banque de batteries</string>
<string name="battery_metric_count">Batteries</string>
<string name="battery_metric_capacity">Capacité</string>
<string name="battery_metric_usable_capacity">Capacité utilisable</string>
<string name="battery_metric_usable_energy">Énergie utilisable</string>
<string name="battery_badge_voltage">Tension</string>
<string name="battery_badge_energy">Énergie</string>
<string name="battery_banner_voltage">Écart de tension détecté</string>
<string name="battery_banner_capacity">Écart de capacité détecté</string>
<string name="battery_empty_title">Aucune batterie pour l\'instant</string>
<string name="battery_empty_create">Ajouter une batterie</string>
<string name="battery_onboarding_title">Ajoutez votre première batterie</string>
<string name="battery_onboarding_subtitle">Suivez la capacité et la chimie de votre banc pour mieux maîtriser l\'autonomie.</string>
<!-- Battery editor -->
<string name="battery_field_name">Nom</string>
<string name="battery_field_chemistry">Chimie</string>
<string name="battery_section_advanced">Avancé</string>
<string name="battery_slider_voltage">Tension nominale</string>
<string name="battery_slider_capacity">Capacité</string>
<string name="battery_slider_usable_capacity">Capacité utilisable (%)</string>
<string name="battery_slider_charge_voltage">Tension de charge</string>
<string name="battery_slider_cutoff_voltage">Tension de coupure</string>
<string name="battery_slider_temperature_range">Plage de température</string>
<string name="battery_temp_min">Minimum</string>
<string name="battery_temp_max">Maximum</string>
<string name="battery_button_reset_default">Réinitialiser</string>
<string name="battery_charge_helper">Définissez la tension de charge maximale recommandée.</string>
<string name="battery_cutoff_helper">Définissez la tension minimale de décharge sûre.</string>
<string name="battery_temp_helper">Définissez la plage de température de fonctionnement recommandée.</string>
<string name="battery_usable_footer_default">Par défaut %s selon la chimie.</string>
<string name="battery_usable_footer_override">Remplacement actif. La valeur par défaut liée à la chimie reste %s.</string>
<string name="battery_alert_voltage_title">Modifier la tension nominale</string>
<string name="battery_alert_capacity_title">Modifier la capacité</string>
<string name="battery_alert_usable_title">Modifier la capacité utilisable</string>
<string name="battery_alert_charge_title">Modifier la tension de charge</string>
<string name="battery_alert_cutoff_title">Modifier la tension de coupure</string>
<string name="battery_alert_min_temp_title">Modifier la température minimale</string>
<string name="battery_alert_max_temp_title">Modifier la température maximale</string>
<string name="battery_appearance_title">Apparence de la batterie</string>
<string name="battery_appearance_subtitle">Personnalisez l\'affichage de cette batterie</string>
<!-- Chargers -->
<string name="chargers_summary_title">Aperçu de charge</string>
<string name="chargers_metric_count">Chargeurs</string>
<string name="chargers_metric_output">Tension de sortie</string>
<string name="chargers_metric_current">Courant de charge</string>
<string name="chargers_metric_power">Puissance de charge</string>
<string name="chargers_badge_input">Entrée</string>
<string name="chargers_badge_output">Sortie</string>
<string name="chargers_badge_current">Courant</string>
<string name="chargers_badge_power">Puissance</string>
<string name="chargers_onboarding_title">Ajoutez vos chargeurs</string>
<string name="chargers_onboarding_subtitle">Suivez l\'alimentation secteur, les chargeurs d\'alternateur et les régulateurs solaires pour connaître votre capacité de charge.</string>
<string name="chargers_onboarding_primary">Créer un chargeur</string>
<!-- Charger editor -->
<string name="charger_field_name">Nom</string>
<string name="charger_field_input_voltage">Tension d\'entrée</string>
<string name="charger_field_output_voltage">Tension de sortie</string>
<string name="charger_field_current">Courant de charge</string>
<string name="charger_field_power">Puissance de charge</string>
<string name="charger_field_power_footer">Laissez vide si la puissance nominale n\'est pas indiquée. Nous la calculerons à partir de la tension et du courant.</string>
<string name="charger_source_type">Source d\'énergie</string>
<string name="charger_source_shore">Courant de quai</string>
<string name="charger_source_solar">Solaire</string>
<string name="charger_source_wind">Éolienne</string>
<string name="charger_source_generator">Groupe électrogène</string>
<string name="charger_source_alternator">Alternateur</string>
<string name="charger_alert_input_voltage_title">Modifier la tension d\'entrée</string>
<string name="charger_alert_output_voltage_title">Modifier la tension de sortie</string>
<string name="charger_alert_current_title">Modifier le courant de charge</string>
<string name="charger_alert_power_title">Modifier la puissance de charge</string>
<string name="charger_alert_voltage_message">Saisissez la tension en volts (V)</string>
<string name="charger_alert_current_message">Saisissez le courant en ampères (A)</string>
<string name="charger_alert_power_message">Saisissez la puissance en watts (W)</string>
<string name="charger_appearance_title">Apparence du chargeur</string>
<string name="charger_appearance_subtitle">Personnalisez l\'affichage de ce chargeur</string>
<!-- Overview -->
<string name="overview_system_header_title">Aperçu du système</string>
<string name="overview_runtime_title">Autonomie estimée</string>
<string name="overview_runtime_subtitle">À charge maximale</string>
<string name="overview_runtime_placeholder">Ajouter capacité</string>
<string name="overview_runtime_goal_title">Objectif d\'autonomie</string>
<string name="overview_chargetime_title">Temps de charge estimé</string>
<string name="overview_chargetime_subtitle">Au débit de charge combiné</string>
<string name="overview_chargetime_placeholder">Ajouter des chargeurs</string>
<string name="overview_chargetime_goal_title">Objectif de recharge</string>
<string name="overview_bom_title">Liste de matériel</string>
<string name="overview_bom_subtitle">Touchez pour consulter les composants</string>
<string name="overview_bom_placeholder">Ajouter des charges</string>
<string name="overview_goal_label">Objectif %s</string>
<string name="overview_goal_clear">Supprimer l\'objectif</string>
<string name="overview_goal_cancel">Annuler</string>
<string name="overview_goal_save">Enregistrer</string>
<string name="overview_loads_empty_title">Aucune charge configurée pour l\'instant</string>
<string name="overview_loads_empty_subtitle">Ajoutez des composants pour obtenir des recommandations de câbles et de fusibles adaptées à ce système.</string>
<string name="overview_chargers_header_title">Vue d\'ensemble des chargeurs</string>
<string name="overview_chargers_empty_title">Aucun chargeur configuré pour l\'instant</string>
<string name="overview_chargers_empty_subtitle">Ajoutez des chargeurs secteur, DC-DC ou solaires pour comprendre votre capacité de charge.</string>
<string name="overview_chargers_empty_create">Ajouter un chargeur</string>
<string name="overview_share_pdf">Rapport complet (PDF)</string>
<!-- Goal editor steppers -->
<string name="goal_days">Jours</string>
<string name="goal_hours">Heures</string>
<string name="goal_minutes">Minutes</string>
<!-- Bill of Materials -->
<string name="bom_navigation_title">Liste de matériel</string>
<string name="bom_empty_message">Aucun composant enregistré pour ce système pour l\'instant.</string>
<string name="bom_export_pdf_button">Exporter en PDF</string>
<string name="bom_item_cable_red">Câble d\'alimentation (rouge)</string>
<string name="bom_item_cable_black">Câble d\'alimentation (noir)</string>
<string name="bom_item_fuse">Fusible &amp; porte-fusible</string>
<string name="bom_item_terminals">Cosses / bornes</string>
<string name="bom_fuse_detail">Porte-fusible en ligne et fusible de %dA</string>
<string name="bom_terminals_detail">Cosses à œillet ou à fourche adaptées aux câbles de %s</string>
<string name="bom_category_components_title">Composants &amp; chargeurs</string>
<string name="bom_category_components_subtitle">Appareils principaux, contrôleurs et équipements de charge.</string>
<string name="bom_category_batteries_title">Batteries</string>
<string name="bom_category_batteries_subtitle">Banques domestiques et stockage.</string>
<string name="bom_category_cables_title">Câbles</string>
<string name="bom_category_cables_subtitle">Liaisons dimensionnées pour chaque circuit.</string>
<string name="bom_category_fuses_title">Fusibles</string>
<string name="bom_category_fuses_subtitle">Protection des circuits et porte-fusibles.</string>
<string name="bom_category_accessories_title">Accessoires</string>
<string name="bom_category_accessories_subtitle">Fusibles, cosses et pièces complémentaires.</string>
<string name="bom_search_device_fallback">appareil DC %1$.0fW %2$.0fV</string>
<string name="bom_search_cable_red">%s câble batterie rouge</string>
<string name="bom_search_cable_black">%s câble batterie noir</string>
<string name="bom_search_fuse">porte-fusible %dA</string>
<string name="bom_search_terminals">%s cosses de câble</string>
<string name="bom_search_battery">%1$dAh %2$dV %3$s batterie</string>
<string name="bom_search_charger">%1$dV %2$dA chargeur de batterie</string>
<string name="bom_pdf_header_title">Liste de matériaux du système</string>
<string name="bom_pdf_placeholder_empty">Aucun composant disponible.</string>
<!-- Overview PDF -->
<string name="overview_pdf_summary_title">Résumé du système</string>
<string name="overview_pdf_summary_runtime">Autonomie estimée</string>
<string name="overview_pdf_summary_chargetime">Temps de charge</string>
<string name="overview_pdf_summary_totalpower">Puissance totale</string>
<string name="overview_pdf_summary_totalcurrent">Courant total</string>
<string name="overview_pdf_summary_batterycapacity">Capacité de batterie</string>
<string name="overview_pdf_summary_chargerpower">Puissance de charge</string>
<string name="overview_pdf_loads_section">Charges</string>
<string name="overview_pdf_batteries_section">Batteries</string>
<string name="overview_pdf_chargers_section">Chargeurs</string>
<string name="overview_pdf_load_voltage">Tension</string>
<string name="overview_pdf_load_current">Courant</string>
<string name="overview_pdf_load_power">Puissance</string>
<string name="overview_pdf_load_cable">Section du câble</string>
<string name="overview_pdf_load_vdrop">Chute de tension</string>
<string name="overview_pdf_load_fuse">Fusible</string>
<string name="overview_pdf_battery_chemistry">Chimie</string>
<string name="overview_pdf_battery_voltage">Tension</string>
<string name="overview_pdf_battery_capacity">Capacité</string>
<string name="overview_pdf_battery_usable">Capacité utilisable</string>
<string name="overview_pdf_battery_energy">Énergie</string>
<string name="overview_pdf_charger_input">Tension d\'entrée</string>
<string name="overview_pdf_charger_output">Tension de sortie</string>
<string name="overview_pdf_charger_current">Courant max.</string>
<string name="overview_pdf_charger_power">Puissance</string>
<!-- Component Library -->
<string name="library_title">Bibliothèque VoltPlan</string>
<string name="library_search_placeholder">Rechercher des composants</string>
<string name="library_error_title">Impossible de charger les composants</string>
<string name="library_retry">Réessayer</string>
<string name="library_empty_title">Aucun composant disponible</string>
<string name="library_empty_subtitle">Revenez bientôt pour découvrir de nouvelles charges VoltPlan.</string>
<string name="library_details_coming">Détails à venir</string>
<!-- Settings -->
<string name="settings_title">Réglages</string>
<string name="settings_units_section">Unités</string>
<string name="units_metric_display">Métrique (mm², m)</string>
<string name="units_imperial_display">Impérial (AWG, ft)</string>
<string name="settings_disclaimer_title">Avertissement de sécurité</string>
<string name="settings_disclaimer_body">Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation.</string>
<string name="settings_disclaimer_points">• Faites toujours appel à des électriciens qualifiés pour les installations réelles\n• Respectez toutes les normes et réglementations électriques locales\n• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés\n• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux\n• Les développeurs de l\'application déclinent toute responsabilité quant aux installations électriques</string>
<!-- Misc -->
<string name="component_fallback_name">Composant</string>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="component_count">
<item quantity="one">%d component</item>
<item quantity="other">%d componenten</item>
</plurals>
</resources>

View File

@@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cable</string>
<!-- Actions -->
<string name="action_add">Toevoegen</string>
<string name="action_back">Terug</string>
<string name="action_delete">Verwijderen</string>
<!-- Systems -->
<string name="systems_title">Systemen</string>
<string name="system_list_no_components">Nog geen componenten</string>
<string name="default_system_name">Mijn systeem</string>
<string name="onboarding_systems_title">Maak je eerste systeem</string>
<string name="onboarding_systems_subtitle">Geef je installatie een naam zodat Cable by VoltPlan lasten, bekabeling en aanbevelingen op één plek kan organiseren.</string>
<string name="onboarding_systems_field">Systeemnaam</string>
<string name="onboarding_systems_create">Systeem maken</string>
<!-- Tabs -->
<string name="tab_overview">Overzicht</string>
<string name="tab_components">Componenten</string>
<string name="tab_batteries">Batterijen</string>
<string name="tab_chargers">Laders</string>
<!-- System editor -->
<string name="editor_system_title">Systeem bewerken</string>
<string name="editor_system_name">Naam van het systeem</string>
<string name="editor_system_location">Locatie (optioneel)</string>
<!-- Loads / Components -->
<string name="loads_overview_header_title">Lastenoverzicht</string>
<string name="loads_metric_count">Lasten</string>
<string name="loads_metric_current">Totale stroom</string>
<string name="loads_metric_power">Totaal vermogen</string>
<string name="loads_metric_fuse">Zekering</string>
<string name="loads_metric_cable">Kabel</string>
<string name="loads_metric_length">Lengte</string>
<string name="loads_library_button">Bibliotheek</string>
<string name="loads_status_missing_banner">Rond de configuratie van je lasten af</string>
<string name="loads_onboarding_title">Voeg je eerste component toe</string>
<string name="loads_onboarding_subtitle">Breng je systeem tot leven met componenten en laat Cable by VoltPlan de kabel- en zekeringadviezen verzorgen.</string>
<string name="loads_empty_create">Last toevoegen</string>
<string name="loads_empty_library">Bibliotheek bekijken</string>
<!-- Load editor -->
<string name="editor_load_title">Last bewerken</string>
<string name="editor_load_name">Naam van de last</string>
<string name="editor_load_preview">Voorbeeld</string>
<!-- Calculator sliders -->
<string name="slider_voltage_title">Spanning</string>
<string name="slider_current_title">Stroom</string>
<string name="slider_power_title">Vermogen</string>
<string name="slider_length_title">Kabellengte (%s)</string>
<string name="slider_button_watt">Watt</string>
<string name="slider_button_ampere">Ampère</string>
<!-- Calculator advanced -->
<string name="calculator_advanced_section_title">Geavanceerde instellingen</string>
<string name="calculator_advanced_duty_title">Inschakelduur</string>
<string name="calculator_advanced_duty_helper">Percentage van elke actieve sessie waarin de last daadwerkelijk vermogen vraagt.</string>
<string name="calculator_advanced_usage_title">Dagelijkse aan-tijd</string>
<string name="calculator_advanced_usage_helper">Uren per dag dat de last is ingeschakeld.</string>
<string name="calculator_advanced_usage_unit">u/dag</string>
<string name="calculator_alert_duty_title">Inschakelduur bewerken</string>
<string name="calculator_alert_duty_message">Voer de inschakelduur in als percentage (0-100%).</string>
<string name="calculator_alert_usage_title">Dagelijkse aan-tijd bewerken</string>
<string name="calculator_alert_usage_message">Voer het aantal uren per dag in dat de last actief is.</string>
<!-- Affiliate / BOM button -->
<string name="affiliate_button_review_parts">Onderdelen bekijken</string>
<string name="affiliate_description_with_link">Tik hierboven om een volledige materiaallijst te zien voordat de affiliate-link wordt geopend. Aankopen kunnen VoltPlan ondersteunen.</string>
<string name="affiliate_description_without_link">Tik hierboven om een volledige materiaallijst te zien met aankoopzoekopdrachten die je helpen onderdelen te vinden.</string>
<string name="affiliate_disclaimer">Aankopen via affiliate-links kunnen VoltPlan ondersteunen.</string>
<!-- Batteries -->
<string name="battery_bank_header_title">Accubank</string>
<string name="battery_metric_count">Batterijen</string>
<string name="battery_metric_capacity">Capaciteit</string>
<string name="battery_metric_usable_capacity">Beschikbare capaciteit</string>
<string name="battery_metric_usable_energy">Beschikbare energie</string>
<string name="battery_badge_voltage">Spanning</string>
<string name="battery_badge_energy">Energie</string>
<string name="battery_banner_voltage">Spanningsafwijking gedetecteerd</string>
<string name="battery_banner_capacity">Capaciteitsafwijking gedetecteerd</string>
<string name="battery_empty_title">Nog geen batterijen</string>
<string name="battery_empty_create">Accu toevoegen</string>
<string name="battery_onboarding_title">Voeg je eerste accu toe</string>
<string name="battery_onboarding_subtitle">Houd capaciteit en chemie van de accubank in de gaten om de gebruiksduur te beheersen.</string>
<!-- Battery editor -->
<string name="battery_field_name">Naam</string>
<string name="battery_field_chemistry">Chemie</string>
<string name="battery_section_advanced">Geavanceerd</string>
<string name="battery_slider_voltage">Nominale spanning</string>
<string name="battery_slider_capacity">Capaciteit</string>
<string name="battery_slider_usable_capacity">Beschikbare capaciteit (%)</string>
<string name="battery_slider_charge_voltage">Laadspanning</string>
<string name="battery_slider_cutoff_voltage">Afsluitspanning</string>
<string name="battery_slider_temperature_range">Temperatuurbereik</string>
<string name="battery_temp_min">Minimum</string>
<string name="battery_temp_max">Maximum</string>
<string name="battery_button_reset_default">Resetten</string>
<string name="battery_charge_helper">Stel de maximaal aanbevolen laadspanning in.</string>
<string name="battery_cutoff_helper">Stel de minimale veilige ontlaadspanning in.</string>
<string name="battery_temp_helper">Bepaal het aanbevolen temperatuurbereik voor gebruik.</string>
<string name="battery_usable_footer_default">Standaard %s op basis van de chemie.</string>
<string name="battery_usable_footer_override">Override actief. Chemische standaard blijft %s.</string>
<string name="battery_alert_voltage_title">Nominale spanning bewerken</string>
<string name="battery_alert_capacity_title">Capaciteit bewerken</string>
<string name="battery_alert_usable_title">Beschikbare capaciteit bewerken</string>
<string name="battery_alert_charge_title">Laadspanning bewerken</string>
<string name="battery_alert_cutoff_title">Afsluitspanning bewerken</string>
<string name="battery_alert_min_temp_title">Minimale temperatuur bewerken</string>
<string name="battery_alert_max_temp_title">Maximale temperatuur bewerken</string>
<string name="battery_appearance_title">Uiterlijk van accu</string>
<string name="battery_appearance_subtitle">Bepaal hoe deze accu wordt weergegeven</string>
<!-- Chargers -->
<string name="chargers_summary_title">Laadoverzicht</string>
<string name="chargers_metric_count">Laders</string>
<string name="chargers_metric_output">Uitgangsspanning</string>
<string name="chargers_metric_current">Laadstroom</string>
<string name="chargers_metric_power">Laadvermogen</string>
<string name="chargers_badge_input">Ingang</string>
<string name="chargers_badge_output">Uitgang</string>
<string name="chargers_badge_current">Stroom</string>
<string name="chargers_badge_power">Vermogen</string>
<string name="chargers_onboarding_title">Voeg je laders toe</string>
<string name="chargers_onboarding_subtitle">Houd walstroomvoedingen, dynamoladers en zonne-regelaars bij om je laadcapaciteit te begrijpen.</string>
<string name="chargers_onboarding_primary">Lader aanmaken</string>
<!-- Charger editor -->
<string name="charger_field_name">Naam</string>
<string name="charger_field_input_voltage">Ingangsspanning</string>
<string name="charger_field_output_voltage">Uitgangsspanning</string>
<string name="charger_field_current">Laadstroom</string>
<string name="charger_field_power">Laadvermogen</string>
<string name="charger_field_power_footer">Laat leeg als het opgegeven vermogen ontbreekt. We berekenen het uit spanning en stroom.</string>
<string name="charger_source_type">Stroombron</string>
<string name="charger_source_shore">Walstroom</string>
<string name="charger_source_solar">Zonne-energie</string>
<string name="charger_source_wind">Wind</string>
<string name="charger_source_generator">Generator</string>
<string name="charger_source_alternator">Dynamo</string>
<string name="charger_alert_input_voltage_title">Ingangsspanning bewerken</string>
<string name="charger_alert_output_voltage_title">Uitgangsspanning bewerken</string>
<string name="charger_alert_current_title">Laadstroom bewerken</string>
<string name="charger_alert_power_title">Laadvermogen bewerken</string>
<string name="charger_alert_voltage_message">Voer de spanning in volt (V) in</string>
<string name="charger_alert_current_message">Voer de stroom in ampère (A) in</string>
<string name="charger_alert_power_message">Voer het vermogen in watt (W) in</string>
<string name="charger_appearance_title">Uiterlijk van lader</string>
<string name="charger_appearance_subtitle">Bepaal hoe deze lader wordt weergegeven</string>
<!-- Overview -->
<string name="overview_system_header_title">Systeemoverzicht</string>
<string name="overview_runtime_title">Geschatte looptijd</string>
<string name="overview_runtime_subtitle">Bij maximale belasting</string>
<string name="overview_runtime_placeholder">Capaciteit toevoegen</string>
<string name="overview_runtime_goal_title">Looptijddoel</string>
<string name="overview_chargetime_title">Geschatte laadtijd</string>
<string name="overview_chargetime_subtitle">Met gecombineerde laadsnelheid</string>
<string name="overview_chargetime_placeholder">Laders toevoegen</string>
<string name="overview_chargetime_goal_title">Laadtijddoel</string>
<string name="overview_bom_title">Stuklijst</string>
<string name="overview_bom_subtitle">Tik om componenten te bekijken</string>
<string name="overview_bom_placeholder">Lasten toevoegen</string>
<string name="overview_goal_label">Doel %s</string>
<string name="overview_goal_clear">Doel verwijderen</string>
<string name="overview_goal_cancel">Annuleren</string>
<string name="overview_goal_save">Opslaan</string>
<string name="overview_loads_empty_title">Nog geen lasten geconfigureerd</string>
<string name="overview_loads_empty_subtitle">Voeg componenten toe om kabel- en zekeringadviezen te krijgen die zijn afgestemd op dit systeem.</string>
<string name="overview_chargers_header_title">Overzicht van laders</string>
<string name="overview_chargers_empty_title">Nog geen laders geconfigureerd</string>
<string name="overview_chargers_empty_subtitle">Voeg walstroom-, DC-DC- of zonneladers toe om je laadcapaciteit te begrijpen.</string>
<string name="overview_chargers_empty_create">Lader toevoegen</string>
<string name="overview_share_pdf">Volledig rapport (PDF)</string>
<!-- Goal editor steppers -->
<string name="goal_days">Dagen</string>
<string name="goal_hours">Uren</string>
<string name="goal_minutes">Minuten</string>
<!-- Bill of Materials -->
<string name="bom_navigation_title">Materiaallijst</string>
<string name="bom_empty_message">Er zijn nog geen componenten voor dit systeem opgeslagen.</string>
<string name="bom_export_pdf_button">PDF exporteren</string>
<string name="bom_item_cable_red">Voedingskabel (rood)</string>
<string name="bom_item_cable_black">Voedingskabel (zwart)</string>
<string name="bom_item_fuse">Zekering &amp; houder</string>
<string name="bom_item_terminals">Kabelschoenen / klemmen</string>
<string name="bom_fuse_detail">In-line houder en zekering van %dA</string>
<string name="bom_terminals_detail">Ring- of vorkklemmen geschikt voor %s-bekabeling</string>
<string name="bom_category_components_title">Componenten &amp; laders</string>
<string name="bom_category_components_subtitle">Hoofdapparaten, regelaars en laadapparatuur.</string>
<string name="bom_category_batteries_title">Batterijen</string>
<string name="bom_category_batteries_subtitle">Huishoudbanken en opslag.</string>
<string name="bom_category_cables_title">Kabels</string>
<string name="bom_category_cables_subtitle">Op maat gemaakte stroomtrajecten per circuit.</string>
<string name="bom_category_fuses_title">Zekeringen</string>
<string name="bom_category_fuses_subtitle">Circuitbeveiliging en houders.</string>
<string name="bom_category_accessories_title">Accessoires</string>
<string name="bom_category_accessories_subtitle">Zekeringen, kabelschoenen en ondersteunende onderdelen.</string>
<string name="bom_search_device_fallback">DC apparaat %1$.0fW %2$.0fV</string>
<string name="bom_search_cable_red">%s rode accukabel</string>
<string name="bom_search_cable_black">%s zwarte accukabel</string>
<string name="bom_search_fuse">inline zekeringhouder %dA</string>
<string name="bom_search_terminals">%s kabelschoenen</string>
<string name="bom_search_battery">%1$dAh %2$dV %3$s batterij</string>
<string name="bom_search_charger">%1$dV %2$dA acculader</string>
<string name="bom_pdf_header_title">Stuklijst van het systeem</string>
<string name="bom_pdf_placeholder_empty">Geen componenten beschikbaar.</string>
<!-- Overview PDF -->
<string name="overview_pdf_summary_title">Systeemoverzicht</string>
<string name="overview_pdf_summary_runtime">Geschatte looptijd</string>
<string name="overview_pdf_summary_chargetime">Laadtijd</string>
<string name="overview_pdf_summary_totalpower">Totaal vermogen</string>
<string name="overview_pdf_summary_totalcurrent">Totale stroom</string>
<string name="overview_pdf_summary_batterycapacity">Accucapaciteit</string>
<string name="overview_pdf_summary_chargerpower">Laadvermogen</string>
<string name="overview_pdf_loads_section">Verbruikers</string>
<string name="overview_pdf_batteries_section">Accu\'s</string>
<string name="overview_pdf_chargers_section">Laders</string>
<string name="overview_pdf_load_voltage">Spanning</string>
<string name="overview_pdf_load_current">Stroom</string>
<string name="overview_pdf_load_power">Vermogen</string>
<string name="overview_pdf_load_cable">Kabeldoorsnede</string>
<string name="overview_pdf_load_vdrop">Spanningsval</string>
<string name="overview_pdf_load_fuse">Zekering</string>
<string name="overview_pdf_battery_chemistry">Chemie</string>
<string name="overview_pdf_battery_voltage">Spanning</string>
<string name="overview_pdf_battery_capacity">Capaciteit</string>
<string name="overview_pdf_battery_usable">Bruikbare capaciteit</string>
<string name="overview_pdf_battery_energy">Energie</string>
<string name="overview_pdf_charger_input">Ingangsspanning</string>
<string name="overview_pdf_charger_output">Uitgangsspanning</string>
<string name="overview_pdf_charger_current">Max. stroom</string>
<string name="overview_pdf_charger_power">Vermogen</string>
<!-- Component Library -->
<string name="library_title">VoltPlan-bibliotheek</string>
<string name="library_search_placeholder">Componenten zoeken</string>
<string name="library_error_title">Componenten konden niet worden geladen</string>
<string name="library_retry">Opnieuw</string>
<string name="library_empty_title">Geen componenten beschikbaar</string>
<string name="library_empty_subtitle">Kom binnenkort terug voor nieuwe lasten van VoltPlan.</string>
<string name="library_details_coming">Details volgen binnenkort</string>
<!-- Settings -->
<string name="settings_title">Instellingen</string>
<string name="settings_units_section">Eenheden</string>
<string name="units_metric_display">Metrisch (mm², m)</string>
<string name="units_imperial_display">Imperiaal (AWG, ft)</string>
<string name="settings_disclaimer_title">Veiligheidswaarschuwing</string>
<string name="settings_disclaimer_body">Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.</string>
<string name="settings_disclaimer_points">• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien\n• Volg alle lokale elektrische voorschriften en regels\n• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals\n• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening\n• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties</string>
<!-- Misc -->
<string name="component_fallback_name">Component</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#519098</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="component_count">
<item quantity="one">%d component</item>
<item quantity="other">%d components</item>
</plurals>
</resources>

View File

@@ -0,0 +1,263 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Cable</string>
<!-- Actions -->
<string name="action_add">Add</string>
<string name="action_back">Back</string>
<string name="action_delete">Delete</string>
<!-- Systems -->
<string name="systems_title">Systems</string>
<string name="system_list_no_components">No components yet</string>
<string name="default_system_name">My System</string>
<string name="onboarding_systems_title">Create your first system</string>
<string name="onboarding_systems_subtitle">Give your setup a name so Cable by VoltPlan can organize loads, wiring, and recommendations in one place.</string>
<string name="onboarding_systems_field">System Name</string>
<string name="onboarding_systems_create">Create System</string>
<!-- Tabs -->
<string name="tab_overview">Overview</string>
<string name="tab_components">Components</string>
<string name="tab_batteries">Batteries</string>
<string name="tab_chargers">Chargers</string>
<!-- System editor -->
<string name="editor_system_title">Edit System</string>
<string name="editor_system_name">System name</string>
<string name="editor_system_location">Location (optional)</string>
<!-- Loads / Components -->
<string name="loads_overview_header_title">Load Overview</string>
<string name="loads_metric_count">Loads</string>
<string name="loads_metric_current">Total Current</string>
<string name="loads_metric_power">Total Power</string>
<string name="loads_metric_fuse">Fuse</string>
<string name="loads_metric_cable">Cable</string>
<string name="loads_metric_length">Length</string>
<string name="loads_library_button">Library</string>
<string name="loads_status_missing_banner">Finish configuring your loads</string>
<string name="loads_onboarding_title">Add your first component</string>
<string name="loads_onboarding_subtitle">Bring your system to life with components and let Cable by VoltPlan handle cable and fuse recommendations.</string>
<string name="loads_empty_create">Add Load</string>
<string name="loads_empty_library">Browse Library</string>
<!-- Load editor -->
<string name="editor_load_title">Edit Load</string>
<string name="editor_load_name">Load name</string>
<string name="editor_load_preview">Preview</string>
<!-- Calculator sliders -->
<string name="slider_voltage_title">Voltage</string>
<string name="slider_current_title">Current</string>
<string name="slider_power_title">Power</string>
<string name="slider_length_title">Cable Length (%s)</string>
<string name="slider_button_watt">Watt</string>
<string name="slider_button_ampere">Ampere</string>
<!-- Calculator advanced -->
<string name="calculator_advanced_section_title">Advanced Settings</string>
<string name="calculator_advanced_duty_title">Duty Cycle</string>
<string name="calculator_advanced_duty_helper">Percentage of each active session where the load actually draws power.</string>
<string name="calculator_advanced_usage_title">Daily On-Time</string>
<string name="calculator_advanced_usage_helper">Hours per day the load is turned on.</string>
<string name="calculator_advanced_usage_unit">h/day</string>
<string name="calculator_alert_duty_title">Edit Duty Cycle</string>
<string name="calculator_alert_duty_message">Enter duty cycle as a percentage (0-100%).</string>
<string name="calculator_alert_usage_title">Edit Daily On-Time</string>
<string name="calculator_alert_usage_message">Enter the number of hours per day the load is active.</string>
<!-- Affiliate / BOM button -->
<string name="affiliate_button_review_parts">Review parts</string>
<string name="affiliate_description_with_link">Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.</string>
<string name="affiliate_description_without_link">Tapping above shows a full bill of materials with shopping searches to help you source parts.</string>
<string name="affiliate_disclaimer">Purchases through affiliate links may support VoltPlan.</string>
<!-- Batteries -->
<string name="battery_bank_header_title">Battery Bank</string>
<string name="battery_metric_count">Batteries</string>
<string name="battery_metric_capacity">Capacity</string>
<string name="battery_metric_usable_capacity">Usable Capacity</string>
<string name="battery_metric_usable_energy">Usable Energy</string>
<string name="battery_badge_voltage">Voltage</string>
<string name="battery_badge_energy">Energy</string>
<string name="battery_banner_voltage">Voltage mismatch detected</string>
<string name="battery_banner_capacity">Capacity mismatch detected</string>
<string name="battery_empty_title">No Batteries Yet</string>
<string name="battery_empty_create">Add Battery</string>
<string name="battery_onboarding_title">Add your first battery</string>
<string name="battery_onboarding_subtitle">Track your bank\'s capacity and chemistry to keep runtime expectations in check.</string>
<!-- Battery editor -->
<string name="battery_field_name">Name</string>
<string name="battery_field_chemistry">Chemistry</string>
<string name="battery_section_advanced">Advanced</string>
<string name="battery_slider_voltage">Nominal Voltage</string>
<string name="battery_slider_capacity">Capacity</string>
<string name="battery_slider_usable_capacity">Usable Capacity (%)</string>
<string name="battery_slider_charge_voltage">Charge Voltage</string>
<string name="battery_slider_cutoff_voltage">Cut-off Voltage</string>
<string name="battery_slider_temperature_range">Temperature Range</string>
<string name="battery_temp_min">Minimum</string>
<string name="battery_temp_max">Maximum</string>
<string name="battery_button_reset_default">Reset</string>
<string name="battery_charge_helper">Set the maximum recommended charging voltage.</string>
<string name="battery_cutoff_helper">Set the minimum safe discharge voltage.</string>
<string name="battery_temp_helper">Define the recommended operating temperature range.</string>
<string name="battery_usable_footer_default">Defaults to %s based on chemistry.</string>
<string name="battery_usable_footer_override">Override active. Chemistry default remains %s.</string>
<string name="battery_alert_voltage_title">Edit Nominal Voltage</string>
<string name="battery_alert_capacity_title">Edit Capacity</string>
<string name="battery_alert_usable_title">Edit Usable Capacity</string>
<string name="battery_alert_charge_title">Edit Charge Voltage</string>
<string name="battery_alert_cutoff_title">Edit Cut-off Voltage</string>
<string name="battery_alert_min_temp_title">Edit Minimum Temperature</string>
<string name="battery_alert_max_temp_title">Edit Maximum Temperature</string>
<string name="battery_appearance_title">Battery Appearance</string>
<string name="battery_appearance_subtitle">Customize how this battery shows up</string>
<!-- Chargers -->
<string name="chargers_summary_title">Charging Overview</string>
<string name="chargers_metric_count">Chargers</string>
<string name="chargers_metric_output">Output Voltage</string>
<string name="chargers_metric_current">Charge Rate</string>
<string name="chargers_metric_power">Charge Power</string>
<string name="chargers_badge_input">Input</string>
<string name="chargers_badge_output">Output</string>
<string name="chargers_badge_current">Current</string>
<string name="chargers_badge_power">Power</string>
<string name="chargers_onboarding_title">Add your chargers</string>
<string name="chargers_onboarding_subtitle">Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity.</string>
<string name="chargers_onboarding_primary">Create Charger</string>
<!-- Charger editor -->
<string name="charger_field_name">Name</string>
<string name="charger_field_input_voltage">Input Voltage</string>
<string name="charger_field_output_voltage">Output Voltage</string>
<string name="charger_field_current">Charge Current</string>
<string name="charger_field_power">Charge Power</string>
<string name="charger_field_power_footer">Leave blank when the rated wattage isn\'t published. We\'ll calculate it from voltage and current.</string>
<string name="charger_source_type">Power Source</string>
<string name="charger_source_shore">Shore Power</string>
<string name="charger_source_solar">Solar</string>
<string name="charger_source_wind">Wind</string>
<string name="charger_source_generator">Generator</string>
<string name="charger_source_alternator">Alternator</string>
<string name="charger_alert_input_voltage_title">Edit Input Voltage</string>
<string name="charger_alert_output_voltage_title">Edit Output Voltage</string>
<string name="charger_alert_current_title">Edit Charge Current</string>
<string name="charger_alert_power_title">Edit Charge Power</string>
<string name="charger_alert_voltage_message">Enter voltage in volts (V)</string>
<string name="charger_alert_current_message">Enter current in amps (A)</string>
<string name="charger_alert_power_message">Enter power in watts (W)</string>
<string name="charger_appearance_title">Charger Appearance</string>
<string name="charger_appearance_subtitle">Customize how this charger shows up</string>
<!-- Overview -->
<string name="overview_system_header_title">System Overview</string>
<string name="overview_runtime_title">Estimated runtime</string>
<string name="overview_runtime_subtitle">At maximum load draw</string>
<string name="overview_runtime_placeholder">Add capacity</string>
<string name="overview_runtime_goal_title">Runtime Goal</string>
<string name="overview_chargetime_title">Estimated charge time</string>
<string name="overview_chargetime_subtitle">At combined charge rate</string>
<string name="overview_chargetime_placeholder">Add chargers</string>
<string name="overview_chargetime_goal_title">Charge Goal</string>
<string name="overview_bom_title">Bill of Materials</string>
<string name="overview_bom_subtitle">Tap to review components</string>
<string name="overview_bom_placeholder">Add loads</string>
<string name="overview_goal_label">Goal %s</string>
<string name="overview_goal_clear">Remove Goal</string>
<string name="overview_goal_cancel">Cancel</string>
<string name="overview_goal_save">Save</string>
<string name="overview_loads_empty_title">No loads configured yet</string>
<string name="overview_loads_empty_subtitle">Add components to get cable sizing and fuse recommendations tailored to this system.</string>
<string name="overview_chargers_header_title">Charger Overview</string>
<string name="overview_chargers_empty_title">No chargers configured yet</string>
<string name="overview_chargers_empty_subtitle">Add shore power, DC-DC, or solar chargers to understand your charging capacity.</string>
<string name="overview_chargers_empty_create">Add Charger</string>
<string name="overview_share_pdf">Full Report (PDF)</string>
<!-- Goal editor steppers -->
<string name="goal_days">Days</string>
<string name="goal_hours">Hours</string>
<string name="goal_minutes">Minutes</string>
<!-- Bill of Materials -->
<string name="bom_navigation_title">Bill of Materials</string>
<string name="bom_empty_message">No components saved in this system yet.</string>
<string name="bom_export_pdf_button">Export PDF</string>
<string name="bom_item_cable_red">Power Cable (Red)</string>
<string name="bom_item_cable_black">Power Cable (Black)</string>
<string name="bom_item_fuse">Fuse &amp; Holder</string>
<string name="bom_item_terminals">Cable Shoes / Terminals</string>
<string name="bom_fuse_detail">Inline holder and %dA fuse</string>
<string name="bom_terminals_detail">Ring or spade terminals sized for %s wiring</string>
<string name="bom_category_components_title">Components &amp; Chargers</string>
<string name="bom_category_components_subtitle">Primary devices, controllers, and charging gear.</string>
<string name="bom_category_batteries_title">Batteries</string>
<string name="bom_category_batteries_subtitle">House banks and storage.</string>
<string name="bom_category_cables_title">Cables</string>
<string name="bom_category_cables_subtitle">Sized power runs for every circuit.</string>
<string name="bom_category_fuses_title">Fuses</string>
<string name="bom_category_fuses_subtitle">Circuit protection and holders.</string>
<string name="bom_category_accessories_title">Accessories</string>
<string name="bom_category_accessories_subtitle">Fuses, lugs, and supporting hardware.</string>
<string name="bom_search_device_fallback">DC device %1$.0fW %2$.0fV</string>
<string name="bom_search_cable_red">%s red battery cable</string>
<string name="bom_search_cable_black">%s black battery cable</string>
<string name="bom_search_fuse">inline fuse holder %dA</string>
<string name="bom_search_terminals">%s cable shoes</string>
<string name="bom_search_battery">%1$dAh %2$dV %3$s battery</string>
<string name="bom_search_charger">%1$dV %2$dA battery charger</string>
<string name="bom_pdf_header_title">System Bill of Materials</string>
<string name="bom_pdf_placeholder_empty">No components available.</string>
<!-- Overview PDF -->
<string name="overview_pdf_summary_title">System Summary</string>
<string name="overview_pdf_summary_runtime">Estimated Runtime</string>
<string name="overview_pdf_summary_chargetime">Charge Time</string>
<string name="overview_pdf_summary_totalpower">Total Power</string>
<string name="overview_pdf_summary_totalcurrent">Total Current</string>
<string name="overview_pdf_summary_batterycapacity">Battery Capacity</string>
<string name="overview_pdf_summary_chargerpower">Charger Power</string>
<string name="overview_pdf_loads_section">Loads</string>
<string name="overview_pdf_batteries_section">Batteries</string>
<string name="overview_pdf_chargers_section">Chargers</string>
<string name="overview_pdf_load_voltage">Voltage</string>
<string name="overview_pdf_load_current">Current</string>
<string name="overview_pdf_load_power">Power</string>
<string name="overview_pdf_load_cable">Cable Size</string>
<string name="overview_pdf_load_vdrop">Voltage Drop</string>
<string name="overview_pdf_load_fuse">Fuse</string>
<string name="overview_pdf_battery_chemistry">Chemistry</string>
<string name="overview_pdf_battery_voltage">Voltage</string>
<string name="overview_pdf_battery_capacity">Capacity</string>
<string name="overview_pdf_battery_usable">Usable Capacity</string>
<string name="overview_pdf_battery_energy">Energy</string>
<string name="overview_pdf_charger_input">Input Voltage</string>
<string name="overview_pdf_charger_output">Output Voltage</string>
<string name="overview_pdf_charger_current">Max Current</string>
<string name="overview_pdf_charger_power">Power</string>
<!-- Component Library -->
<string name="library_title">VoltPlan Library</string>
<string name="library_search_placeholder">Search components</string>
<string name="library_error_title">Unable to load components</string>
<string name="library_retry">Retry</string>
<string name="library_empty_title">No components available</string>
<string name="library_empty_subtitle">Check back soon for new loads from VoltPlan.</string>
<string name="library_details_coming">Details coming soon</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_units_section">Units</string>
<string name="units_metric_display">Metric (mm², m)</string>
<string name="units_imperial_display">Imperial (AWG, ft)</string>
<string name="settings_disclaimer_title">Safety Disclaimer</string>
<string name="settings_disclaimer_body">This application provides electrical calculations for educational and estimation purposes only.</string>
<string name="settings_disclaimer_points">• Always consult qualified electricians for actual installations\n• Follow all local electrical codes and regulations\n• Electrical work should only be performed by licensed professionals\n• These calculations may not account for all environmental factors\n• The app developers assume no liability for electrical installations</string>
<!-- Misc -->
<string name="component_fallback_name">Component</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Cable" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
<files-path name="files" path="." />
</paths>