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:
13
android/.gitignore
vendored
Normal file
13
android/.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
*.iml
|
||||
.gradle/
|
||||
/local.properties
|
||||
/.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
/app/build/
|
||||
|
||||
# Local build artifacts
|
||||
*.apk
|
||||
71
android/README.md
Normal file
71
android/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Cable for Android
|
||||
|
||||
A native Kotlin / Jetpack Compose port of the **Cable by VoltPlan** iOS app — a tool for
|
||||
sizing low-voltage electrical conductors (boats, RVs, off-grid). This module reproduces every
|
||||
feature of the iOS app with an Android-adapted navigation structure and the same Aptabase
|
||||
analytics backend.
|
||||
|
||||
## Feature parity with iOS
|
||||
|
||||
- **Systems** — list, create (with name-derived icons), delete, onboarding empty state.
|
||||
- **System detail** — Android bottom-navigation tabs (Overview · Components · Batteries · Chargers),
|
||||
replacing the iOS `TabView`.
|
||||
- **Calculator / Loads** — voltage/current/power/length sliders with snap-to-common-values and
|
||||
tap-to-edit dialogs, watt⇄ampere mode, duty cycle & daily on-time (advanced), live wire-gauge /
|
||||
voltage-drop / power-loss / fuse results, and a per-load Bill of Materials sheet.
|
||||
- **Batteries** — chemistry picker, nominal voltage, capacity, usable-capacity override, charge &
|
||||
cut-off voltage, temperature range; bank voltage/capacity mismatch warnings.
|
||||
- **Chargers** — power source picker (shore/solar/wind/generator/alternator), input/output voltage,
|
||||
and a charge-output field that toggles between current and power entry.
|
||||
- **Overview** — estimated runtime & charge time with editable goals, BOM completion progress, and
|
||||
loads/batteries/chargers summary cards.
|
||||
- **Bill of Materials** — categorized checklist (components, batteries, cables, fuses, accessories),
|
||||
completion tracking persisted per component, and locale-aware Amazon affiliate / search links.
|
||||
- **Component Library** — VoltPlan PocketBase backend (`https://base.voltplan.app`) with pagination,
|
||||
multi-language translations, locale-aware affiliate resolution, and Coil-cached remote icons.
|
||||
- **PDF export** — system overview and BOM PDFs via Android `PdfDocument`, shared through a
|
||||
`FileProvider`.
|
||||
- **Localization** — English, German, Spanish, French, Dutch (`values`, `values-de/es/fr/nl`).
|
||||
- **Analytics** — Aptabase (app key `A-SH-4260269603`, host `https://apta.yuzuhub.com`), implemented
|
||||
against the Aptabase ingestion protocol so every iOS event (`App Launched`, `System Created`,
|
||||
`Tab Changed`, `Load/Battery/Charger Created/Deleted`, `BOM Item Tapped`, …) is mirrored. The iOS
|
||||
app uses Aptabase, so Aptabase is the source of truth here.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **UI:** Jetpack Compose + Material 3, Navigation-Compose.
|
||||
- **State:** `ViewModel` + `StateFlow`; auto-save editors mirror the iOS "save on change" behaviour.
|
||||
- **Persistence:** Room (`SwiftData` → `@Entity`), all values stored in metric; imperial is a
|
||||
display-time conversion only (`UnitSystemSettings` via DataStore).
|
||||
- **Calculation engine:** `calc/ElectricalCalculations.kt` is a direct port (0.017 Ω·mm²/m copper
|
||||
resistivity, 5 % voltage-drop limit, AWG/metric tables).
|
||||
- **Networking:** Retrofit + kotlinx.serialization (PocketBase), OkHttp (Aptabase).
|
||||
|
||||
Package root: `app.voltplan.cable`.
|
||||
|
||||
## Building
|
||||
|
||||
This module needs the Android toolchain (JDK 17 + Android SDK 35). The Gradle **wrapper JAR** is a
|
||||
binary and is not checked in here, so generate it once (or just open the folder in Android Studio,
|
||||
which does it automatically):
|
||||
|
||||
```bash
|
||||
cd android
|
||||
# Option A: open in Android Studio (Giraffe+) and let it sync.
|
||||
# Option B: with a system Gradle 8.11+ installed:
|
||||
gradle wrapper --gradle-version 8.11.1
|
||||
./gradlew :app:assembleDebug
|
||||
```
|
||||
|
||||
Create a `local.properties` pointing at your SDK if Studio doesn't:
|
||||
|
||||
```
|
||||
sdk.dir=/path/to/Android/sdk
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- SF Symbol names from the iOS data model are preserved verbatim and translated to Material icons at
|
||||
render time (`ui/Symbols.kt`), so the two platforms share identical stored data.
|
||||
- The in-calculator "saved loads" picker from iOS is intentionally omitted; the VoltPlan component
|
||||
library is the primary picker on Android.
|
||||
89
android/app/build.gradle.kts
Normal file
89
android/app/build.gradle.kts
Normal 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
12
android/app/proguard-rules.pro
vendored
Normal 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
|
||||
40
android/app/src/main/AndroidManifest.xml
Normal file
40
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
25
android/app/src/main/java/app/voltplan/cable/MainActivity.kt
Normal file
25
android/app/src/main/java/app/voltplan/cable/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()}"
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
119
android/app/src/main/java/app/voltplan/cable/data/db/Daos.kt
Normal file
119
android/app/src/main/java/app/voltplan/cable/data/db/Daos.kt
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
87
android/app/src/main/java/app/voltplan/cable/pdf/PdfShare.kt
Normal file
87
android/app/src/main/java/app/voltplan/cable/pdf/PdfShare.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
147
android/app/src/main/java/app/voltplan/cable/ui/Symbols.kt
Normal file
147
android/app/src/main/java/app/voltplan/cable/ui/Symbols.kt
Normal 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",
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
208
android/app/src/main/java/app/voltplan/cable/ui/bom/Bom.kt
Normal file
208
android/app/src/main/java/app/voltplan/cable/ui/bom/Bom.kt
Normal 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
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 1–2 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") } },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) }
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)}"
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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 ?: "")))
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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, 0–1 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)
|
||||
}
|
||||
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
10
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
7
android/app/src/main/res/values-de/plurals.xml
Normal file
7
android/app/src/main/res/values-de/plurals.xml
Normal 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>
|
||||
263
android/app/src/main/res/values-de/strings.xml
Normal file
263
android/app/src/main/res/values-de/strings.xml
Normal 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 & 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 & 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>
|
||||
7
android/app/src/main/res/values-es/plurals.xml
Normal file
7
android/app/src/main/res/values-es/plurals.xml
Normal 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>
|
||||
263
android/app/src/main/res/values-es/strings.xml
Normal file
263
android/app/src/main/res/values-es/strings.xml
Normal 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>
|
||||
7
android/app/src/main/res/values-fr/plurals.xml
Normal file
7
android/app/src/main/res/values-fr/plurals.xml
Normal 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>
|
||||
263
android/app/src/main/res/values-fr/strings.xml
Normal file
263
android/app/src/main/res/values-fr/strings.xml
Normal 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 & 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 & 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>
|
||||
7
android/app/src/main/res/values-nl/plurals.xml
Normal file
7
android/app/src/main/res/values-nl/plurals.xml
Normal 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>
|
||||
263
android/app/src/main/res/values-nl/strings.xml
Normal file
263
android/app/src/main/res/values-nl/strings.xml
Normal 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 & 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 & 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>
|
||||
4
android/app/src/main/res/values/colors.xml
Normal file
4
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#519098</color>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/plurals.xml
Normal file
7
android/app/src/main/res/values/plurals.xml
Normal 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>
|
||||
263
android/app/src/main/res/values/strings.xml
Normal file
263
android/app/src/main/res/values/strings.xml
Normal 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 & 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 & 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>
|
||||
4
android/app/src/main/res/values/themes.xml
Normal file
4
android/app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Cable" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
5
android/app/src/main/res/xml/file_paths.xml
Normal file
5
android/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
8
android/build.gradle.kts
Normal file
8
android/build.gradle.kts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Top-level build file. Module-level config lives in app/build.gradle.kts.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
6
android/gradle.properties
Normal file
6
android/gradle.properties
Normal file
@@ -0,0 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
48
android/gradle/libs.versions.toml
Normal file
48
android/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
[versions]
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.1.0"
|
||||
ksp = "2.1.0-1.0.29"
|
||||
coreKtx = "1.15.0"
|
||||
lifecycle = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.12.01"
|
||||
navigationCompose = "2.8.5"
|
||||
room = "2.6.1"
|
||||
datastore = "1.1.1"
|
||||
retrofit = "2.11.0"
|
||||
okhttp = "4.12.0"
|
||||
serialization = "1.7.3"
|
||||
retrofitSerialization = "1.0.0"
|
||||
coil = "2.7.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitSerialization" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
24
android/settings.gradle.kts
Normal file
24
android/settings.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "Cable"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user