From 61f340a87053dd6fccb5a20c3eac3ea318689aee Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Fri, 22 May 2026 10:36:08 +0200 Subject: [PATCH] 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) --- android/.gitignore | 13 + android/README.md | 71 ++++ android/app/build.gradle.kts | 89 +++++ android/app/proguard-rules.pro | 12 + android/app/src/main/AndroidManifest.xml | 40 ++ .../app/voltplan/cable/CableApplication.kt | 33 ++ .../java/app/voltplan/cable/MainActivity.kt | 25 ++ .../cable/affiliate/AmazonAffiliate.kt | 51 +++ .../app/voltplan/cable/analytics/Analytics.kt | 156 ++++++++ .../cable/calc/ElectricalCalculations.kt | 121 ++++++ .../app/voltplan/cable/calc/SystemMetrics.kt | 116 ++++++ .../voltplan/cable/data/CableRepository.kt | 81 +++++ .../app/voltplan/cable/data/UnitSystem.kt | 34 ++ .../voltplan/cable/data/UnitSystemSettings.kt | 55 +++ .../voltplan/cable/data/db/CableDatabase.kt | 42 +++ .../app/voltplan/cable/data/db/Converters.kt | 14 + .../java/app/voltplan/cable/data/db/Daos.kt | 119 ++++++ .../app/voltplan/cable/data/model/Entities.kt | 107 ++++++ .../app/voltplan/cable/data/model/Enums.kt | 50 +++ .../cable/library/ComponentLibraryItem.kt | 126 +++++++ .../library/ComponentLibraryRepository.kt | 53 +++ .../library/ComponentLibraryViewModel.kt | 100 +++++ .../app/voltplan/cable/library/PocketBase.kt | 83 +++++ .../java/app/voltplan/cable/pdf/PdfShare.kt | 87 +++++ .../app/voltplan/cable/pdf/SystemBomPdf.kt | 47 +++ .../voltplan/cable/pdf/SystemOverviewPdf.kt | 92 +++++ .../main/java/app/voltplan/cable/ui/Locals.kt | 9 + .../java/app/voltplan/cable/ui/Symbols.kt | 147 ++++++++ .../cable/ui/batteries/BatteriesTab.kt | 149 ++++++++ .../cable/ui/batteries/BatteryEditorScreen.kt | 260 +++++++++++++ .../ui/batteries/BatteryEditorViewModel.kt | 133 +++++++ .../cable/ui/bom/BillOfMaterialsScreen.kt | 220 +++++++++++ .../cable/ui/bom/BillOfMaterialsViewModel.kt | 100 +++++ .../java/app/voltplan/cable/ui/bom/Bom.kt | 208 +++++++++++ .../cable/ui/chargers/ChargerEditorScreen.kt | 210 +++++++++++ .../ui/chargers/ChargerEditorViewModel.kt | 148 ++++++++ .../voltplan/cable/ui/chargers/ChargersTab.kt | 129 +++++++ .../cable/ui/components/AppearanceEditor.kt | 176 +++++++++ .../voltplan/cable/ui/components/LoadIcon.kt | 67 ++++ .../cable/ui/components/OnboardingInfo.kt | 56 +++ .../cable/ui/components/SnapSlider.kt | 120 ++++++ .../voltplan/cable/ui/components/Widgets.kt | 112 ++++++ .../ui/library/ComponentLibraryScreen.kt | 144 ++++++++ .../cable/ui/loads/CalculatorScreen.kt | 344 ++++++++++++++++++ .../cable/ui/loads/CalculatorViewModel.kt | 150 ++++++++ .../voltplan/cable/ui/loads/ComponentsTab.kt | 155 ++++++++ .../voltplan/cable/ui/loads/LoadFormatting.kt | 40 ++ .../cable/ui/navigation/CableNavHost.kt | 141 +++++++ .../cable/ui/overview/GoalEditorDialog.kt | 89 +++++ .../voltplan/cable/ui/overview/OverviewTab.kt | 229 ++++++++++++ .../cable/ui/settings/SettingsScreen.kt | 79 ++++ .../cable/ui/system/SystemDetailScreen.kt | 251 +++++++++++++ .../cable/ui/system/SystemDetailViewModel.kt | 88 +++++ .../cable/ui/systems/SystemIconMapper.kt | 51 +++ .../cable/ui/systems/SystemsScreen.kt | 204 +++++++++++ .../cable/ui/systems/SystemsViewModel.kt | 75 ++++ .../java/app/voltplan/cable/ui/theme/Color.kt | 47 +++ .../java/app/voltplan/cable/ui/theme/Theme.kt | 46 +++ .../java/app/voltplan/cable/ui/theme/Type.kt | 6 + .../app/voltplan/cable/util/Formatting.kt | 47 +++ .../res/drawable/ic_launcher_foreground.xml | 10 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../app/src/main/res/values-de/plurals.xml | 7 + .../app/src/main/res/values-de/strings.xml | 263 +++++++++++++ .../app/src/main/res/values-es/plurals.xml | 7 + .../app/src/main/res/values-es/strings.xml | 263 +++++++++++++ .../app/src/main/res/values-fr/plurals.xml | 7 + .../app/src/main/res/values-fr/strings.xml | 263 +++++++++++++ .../app/src/main/res/values-nl/plurals.xml | 7 + .../app/src/main/res/values-nl/strings.xml | 263 +++++++++++++ android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/plurals.xml | 7 + android/app/src/main/res/values/strings.xml | 263 +++++++++++++ android/app/src/main/res/values/themes.xml | 4 + android/app/src/main/res/xml/file_paths.xml | 5 + android/build.gradle.kts | 8 + android/gradle.properties | 6 + android/gradle/libs.versions.toml | 48 +++ .../gradle/wrapper/gradle-wrapper.properties | 7 + android/settings.gradle.kts | 24 ++ 81 files changed, 7723 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/app/voltplan/cable/CableApplication.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/MainActivity.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/affiliate/AmazonAffiliate.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/calc/ElectricalCalculations.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/calc/SystemMetrics.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/CableRepository.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/UnitSystem.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/db/CableDatabase.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/db/Converters.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/db/Daos.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/model/Entities.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/data/model/Enums.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/pdf/PdfShare.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/pdf/SystemBomPdf.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/pdf/SystemOverviewPdf.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/Locals.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/Symbols.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/bom/Bom.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/components/LoadIcon.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/components/OnboardingInfo.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/components/SnapSlider.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/components/Widgets.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/loads/LoadFormatting.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/overview/GoalEditorDialog.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/systems/SystemIconMapper.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsViewModel.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/theme/Color.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/ui/theme/Type.kt create mode 100644 android/app/src/main/java/app/voltplan/cable/util/Formatting.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values-de/plurals.xml create mode 100644 android/app/src/main/res/values-de/strings.xml create mode 100644 android/app/src/main/res/values-es/plurals.xml create mode 100644 android/app/src/main/res/values-es/strings.xml create mode 100644 android/app/src/main/res/values-fr/plurals.xml create mode 100644 android/app/src/main/res/values-fr/strings.xml create mode 100644 android/app/src/main/res/values-nl/plurals.xml create mode 100644 android/app/src/main/res/values-nl/strings.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/plurals.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100644 android/settings.gradle.kts diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..d473c7f --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +/app/build/ + +# Local build artifacts +*.apk diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..f78d39f --- /dev/null +++ b/android/README.md @@ -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..c7e870c --- /dev/null +++ b/android/app/build.gradle.kts @@ -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) +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..c3723a7 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..29a265a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/app/voltplan/cable/CableApplication.kt b/android/app/src/main/java/app/voltplan/cable/CableApplication.kt new file mode 100644 index 0000000..90426e2 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/CableApplication.kt @@ -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") + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/MainActivity.kt b/android/app/src/main/java/app/voltplan/cable/MainActivity.kt new file mode 100644 index 0000000..aed7042 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/MainActivity.kt @@ -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() + } + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/affiliate/AmazonAffiliate.kt b/android/app/src/main/java/app/voltplan/cable/affiliate/AmazonAffiliate.kt new file mode 100644 index 0000000..2b04aee --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/affiliate/AmazonAffiliate.kt @@ -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() + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt b/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt new file mode 100644 index 0000000..4bfcf58 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/analytics/Analytics.kt @@ -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 = 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() +} diff --git a/android/app/src/main/java/app/voltplan/cable/calc/ElectricalCalculations.kt b/android/app/src/main/java/app/voltplan/cable/calc/ElectricalCalculations.kt new file mode 100644 index 0000000..c7592d9 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/calc/ElectricalCalculations.kt @@ -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()}" + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/calc/SystemMetrics.kt b/android/app/src/main/java/app/voltplan/cable/calc/SystemMetrics.kt new file mode 100644 index 0000000..d0d362c --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/calc/SystemMetrics.kt @@ -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, + val batteries: List, + val chargers: List, +) { + 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, bin: Double): Double { + val counts = HashMap() + 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" } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/CableRepository.kt b/android/app/src/main/java/app/voltplan/cable/data/CableRepository.kt new file mode 100644 index 0000000..09ec2a7 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/CableRepository.kt @@ -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> = systemDao.observeAll() + fun observeSystem(id: String): Flow = 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> = loadDao.observeAll() + fun observeLoads(systemId: String): Flow> = 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> = 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> = 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 + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/UnitSystem.kt b/android/app/src/main/java/app/voltplan/cable/data/UnitSystem.kt new file mode 100644 index 0000000..382bc5c --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/UnitSystem.kt @@ -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 + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt b/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt new file mode 100644 index 0000000..00ff0f5 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/UnitSystemSettings.kt @@ -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 + + 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 + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/db/CableDatabase.kt b/android/app/src/main/java/app/voltplan/cable/data/db/CableDatabase.kt new file mode 100644 index 0000000..b5f1d03 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/db/CableDatabase.kt @@ -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 } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/db/Converters.kt b/android/app/src/main/java/app/voltplan/cable/data/db/Converters.kt new file mode 100644 index 0000000..59acf4e --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/db/Converters.kt @@ -0,0 +1,14 @@ +package app.voltplan.cable.data.db + +import androidx.room.TypeConverter + +/** Stores `List` (BOM completed item IDs) as a newline-delimited blob. */ +class Converters { + @TypeConverter + fun fromStringList(value: List?): String = + value?.joinToString("\n") ?: "" + + @TypeConverter + fun toStringList(value: String?): List = + if (value.isNullOrEmpty()) emptyList() else value.split("\n").filter { it.isNotEmpty() } +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/db/Daos.kt b/android/app/src/main/java/app/voltplan/cable/data/db/Daos.kt new file mode 100644 index 0000000..4bafc3e --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/db/Daos.kt @@ -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> + + @Query("SELECT * FROM systems WHERE id = :id") + fun observe(id: String): Flow + + @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 + + @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> + + @Query("SELECT * FROM loads WHERE systemId = :systemId ORDER BY timestamp DESC") + fun observeForSystem(systemId: String): Flow> + + @Query("SELECT * FROM loads WHERE systemId = :systemId") + suspend fun listForSystem(systemId: String): List + + @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> + + @Query("SELECT * FROM batteries WHERE systemId = :systemId ORDER BY timestamp DESC") + fun observeForSystem(systemId: String): Flow> + + @Query("SELECT * FROM batteries WHERE systemId = :systemId") + suspend fun listForSystem(systemId: String): List + + @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> + + @Query("SELECT * FROM chargers WHERE systemId = :systemId ORDER BY timestamp DESC") + fun observeForSystem(systemId: String): Flow> + + @Query("SELECT * FROM chargers WHERE systemId = :systemId") + suspend fun listForSystem(systemId: String): List + + @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) +} diff --git a/android/app/src/main/java/app/voltplan/cable/data/model/Entities.kt b/android/app/src/main/java/app/voltplan/cable/data/model/Entities.kt new file mode 100644 index 0000000..875594f --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/model/Entities.kt @@ -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 = 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 = 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 = 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 diff --git a/android/app/src/main/java/app/voltplan/cable/data/model/Enums.kt b/android/app/src/main/java/app/voltplan/cable/data/model/Enums.kt new file mode 100644 index 0000000..d4452e3 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/data/model/Enums.kt @@ -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 + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt new file mode 100644 index 0000000..f058d3e --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryItem.kt @@ -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, + val voltageIn: Double?, + val voltageOut: Double?, + val watt: Double?, + val dutyCyclePercent: Double?, + val defaultUtilizationFactorPercent: Double?, + val iconURL: String?, + val affiliateLinks: List, +) { + 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): 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 { + val obj = (element as? JsonObject) ?: return emptyMap() + val result = LinkedHashMap() + 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 + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt new file mode 100644 index 0000000..ebfcee3 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryRepository.kt @@ -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 { + 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 { + val all = mutableListOf() + 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): Map> { + if (componentIds.isEmpty()) return emptyMap() + val result = HashMap>() + 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 })) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt new file mode 100644 index 0000000..38c7548 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/library/ComponentLibraryViewModel.kt @@ -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 = emptyList(), + val query: String = "", +) { + val filtered: List + 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 = _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) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt b/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt new file mode 100644 index 0000000..ea0c424 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/library/PocketBase.kt @@ -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 = 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 = 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) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/pdf/PdfShare.kt b/android/app/src/main/java/app/voltplan/cable/pdf/PdfShare.kt new file mode 100644 index 0000000..619e11c --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/pdf/PdfShare.kt @@ -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)) + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/pdf/SystemBomPdf.kt b/android/app/src/main/java/app/voltplan/cable/pdf/SystemBomPdf.kt new file mode 100644 index 0000000..0eca02e --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/pdf/SystemBomPdf.kt @@ -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) } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/pdf/SystemOverviewPdf.kt b/android/app/src/main/java/app/voltplan/cable/pdf/SystemOverviewPdf.kt new file mode 100644 index 0000000..ffd6aa5 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/pdf/SystemOverviewPdf.kt @@ -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) + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/Locals.kt b/android/app/src/main/java/app/voltplan/cable/ui/Locals.kt new file mode 100644 index 0000000..1d0174a --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/Locals.kt @@ -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 { + error("UnitSystemSettings not provided") +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/Symbols.kt b/android/app/src/main/java/app/voltplan/cable/ui/Symbols.kt new file mode 100644 index 0000000..c86d06a --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/Symbols.kt @@ -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", +) diff --git a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt new file mode 100644 index 0000000..b86d53a --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteriesTab.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt new file mode 100644 index 0000000..4584449 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorScreen.kt @@ -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(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 }, + ) + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorViewModel.kt b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorViewModel.kt new file mode 100644 index 0000000..c85f0ca --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/batteries/BatteryEditorViewModel.kt @@ -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 = _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)) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt new file mode 100644 index 0000000..6c5ea29 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsScreen.kt @@ -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() } + + 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), + ) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsViewModel.kt b/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsViewModel.kt new file mode 100644 index 0000000..9c8c219 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/bom/BillOfMaterialsViewModel.kt @@ -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 = emptyList(), + /** componentId -> set of completed logical ids */ + val completed: Map> = emptyMap(), + val itemCount: Int = 0, +) + +class BillOfMaterialsViewModel( + private val app: CableApplication, + private val systemId: String, +) : ViewModel() { + private val repo = app.repository + + val state: StateFlow = 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>): 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, value: String, add: Boolean): List { + val set = list.toMutableSet() + if (add) set.add(value) else set.remove(value) + return set.toList() + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/bom/Bom.kt b/android/app/src/main/java/app/voltplan/cable/ui/bom/Bom.kt new file mode 100644 index 0000000..61c53f5 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/bom/Bom.kt @@ -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>, +) + +data class BomSection(val category: BomCategory, val items: List) + +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, + batteries: List, + chargers: List, + unit: UnitSystem, + ): List { + val items = mutableListOf() + 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 diff --git a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt new file mode 100644 index 0000000..33b59fe --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorScreen.kt @@ -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(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 + }, +) diff --git a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorViewModel.kt b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorViewModel.kt new file mode 100644 index 0000000..ba84b25 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargerEditorViewModel.kt @@ -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 = _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)) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt new file mode 100644 index 0000000..f8a5aed --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/chargers/ChargersTab.kt @@ -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) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt b/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt new file mode 100644 index 0000000..d34f429 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/components/AppearanceEditor.kt @@ -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, + 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) +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/components/LoadIcon.kt b/android/app/src/main/java/app/voltplan/cable/ui/components/LoadIcon.kt new file mode 100644 index 0000000..dff5238 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/components/LoadIcon.kt @@ -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), + ) + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/components/OnboardingInfo.kt b/android/app/src/main/java/app/voltplan/cable/ui/components/OnboardingInfo.kt new file mode 100644 index 0000000..4f3c7e4 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/components/OnboardingInfo.kt @@ -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) } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/components/SnapSlider.kt b/android/app/src/main/java/app/voltplan/cable/ui/components/SnapSlider.kt new file mode 100644 index 0000000..60a539f --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/components/SnapSlider.kt @@ -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, + valueText: String, + onValueChange: (Double) -> Unit, + modifier: Modifier = Modifier, + snapValues: List = 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, + 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") } }, + ) +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/components/Widgets.kt b/android/app/src/main/java/app/voltplan/cable/ui/components/Widgets.kt new file mode 100644 index 0000000..54da5c0 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/components/Widgets.kt @@ -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) diff --git a/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt new file mode 100644 index 0000000..7a3e501 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/library/ComponentLibraryScreen.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt new file mode 100644 index 0000000..02d6306 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorScreen.kt @@ -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(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)) } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorViewModel.kt b/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorViewModel.kt new file mode 100644 index 0000000..4c5e87b --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/loads/CalculatorViewModel.kt @@ -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 = 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 = _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())) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt new file mode 100644 index 0000000..81027c5 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/loads/ComponentsTab.kt @@ -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)) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/loads/LoadFormatting.kt b/android/app/src/main/java/app/voltplan/cable/ui/loads/LoadFormatting.kt new file mode 100644 index 0000000..1f8ddd6 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/loads/LoadFormatting.kt @@ -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)}" + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt b/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt new file mode 100644 index 0000000..35476e0 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/navigation/CableNavHost.kt @@ -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() }) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/overview/GoalEditorDialog.kt b/android/app/src/main/java/app/voltplan/cable/ui/overview/GoalEditorDialog.kt new file mode 100644 index 0000000..59cfd7d --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/overview/GoalEditorDialog.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt b/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt new file mode 100644 index 0000000..15cd882 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/overview/OverviewTab.kt @@ -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(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) + } + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/settings/SettingsScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..d0c8c25 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/settings/SettingsScreen.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt new file mode 100644 index 0000000..4b3f0b3 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailScreen.kt @@ -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) } diff --git a/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailViewModel.kt b/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailViewModel.kt new file mode 100644 index 0000000..aa24d41 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/system/SystemDetailViewModel.kt @@ -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 = emptyList(), + val batteries: List = emptyList(), + val chargers: List = 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 = 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 ?: ""))) + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemIconMapper.kt b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemIconMapper.kt new file mode 100644 index 0000000..4638b2b --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemIconMapper.kt @@ -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, 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", + ) +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt new file mode 100644 index 0000000..d5ffe2c --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsScreen.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsViewModel.kt b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsViewModel.kt new file mode 100644 index 0000000..bd4e8dd --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/systems/SystemsViewModel.kt @@ -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> = + 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) diff --git a/android/app/src/main/java/app/voltplan/cable/ui/theme/Color.kt b/android/app/src/main/java/app/voltplan/cable/ui/theme/Color.kt new file mode 100644 index 0000000..0286f98 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/theme/Color.kt @@ -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", +) diff --git a/android/app/src/main/java/app/voltplan/cable/ui/theme/Theme.kt b/android/app/src/main/java/app/voltplan/cable/ui/theme/Theme.kt new file mode 100644 index 0000000..febe334 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/android/app/src/main/java/app/voltplan/cable/ui/theme/Type.kt b/android/app/src/main/java/app/voltplan/cable/ui/theme/Type.kt new file mode 100644 index 0000000..5020c16 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/ui/theme/Type.kt @@ -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() diff --git a/android/app/src/main/java/app/voltplan/cable/util/Formatting.kt b/android/app/src/main/java/app/voltplan/cable/util/Formatting.kt new file mode 100644 index 0000000..c2c3847 --- /dev/null +++ b/android/app/src/main/java/app/voltplan/cable/util/Formatting.kt @@ -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) +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..bcb4d2c --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values-de/plurals.xml b/android/app/src/main/res/values-de/plurals.xml new file mode 100644 index 0000000..2aff7d9 --- /dev/null +++ b/android/app/src/main/res/values-de/plurals.xml @@ -0,0 +1,7 @@ + + + + %d Komponente + %d Komponenten + + diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..7eb699f --- /dev/null +++ b/android/app/src/main/res/values-de/strings.xml @@ -0,0 +1,263 @@ + + + Cable + + + Hinzufügen + Zurück + Löschen + + + Systeme + Noch keine Verbraucher + Mein System + Erstelle dein erstes System + Gib deinem Setup einen Namen, damit Cable by VoltPlan Verbraucher, Verkabelung und Empfehlungen an einem Ort organisieren kann. + Systemname + System erstellen + + + Übersicht + Verbraucher + Batterien + Ladegeräte + + + System bearbeiten + Name des Systems + Standort (optional) + + + Verbraucher + Verbraucher + Strom + Leistung + Sicherung + Schnitt + Länge + Bibliothek + Konfiguration deiner Verbraucher abschließen + Erstelle deinen ersten Verbraucher + Statte dein System mit Verbrauchern aus und lass Cable by VoltPlan die Kabel- und Sicherungsempfehlungen übernehmen. + Verbraucher hinzufügen + Bibliothek durchsuchen + + + Verbraucher bearbeiten + Name des Verbrauchers + Vorschau + + + Spannung + Strom + Leistung + Kabellänge (%s) + Watt + Ampere + + + Erweitert + Einschaltdauer + Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt. + Tägliche Laufzeit + Stunden pro Tag, in denen die Last eingeschaltet ist. + Std./Tag + Einschaltdauer bearbeiten + Einschaltdauer als Prozent (0-100%) eingeben. + Tägliche Laufzeit bearbeiten + Stunden pro Tag eingeben, in denen die Last aktiv ist. + + + Bauteile prüfen + Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen. + Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen. + Käufe über Affiliate-Links können VoltPlan unterstützen. + + + Batterien + Batterien + Kapazität + Nutzbare Kapazität + Nutzbare Energie + Spannung + Energie + Spannungsabweichung erkannt + Kapazitätsabweichung erkannt + Noch keine Batterien + Batterie hinzufügen + Füge deine erste Batterie hinzu + Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten. + + + Name + Chemie + Erweitert + Nennspannung + Kapazität + Nutzbare Kapazität (%) + Ladespannung + Abschaltspannung + Temperaturbereich + Minimum + Maximum + Zurücksetzen + Lege die maximal empfohlene Ladespannung fest. + Lege die minimale sichere Entladespannung fest. + Definiere den empfohlenen Betriebstemperaturbereich. + Standard %s basierend auf der Chemie. + Überschreibung aktiv. Chemie-Standard bleibt %s. + Nennspannung bearbeiten + Kapazität bearbeiten + Nutzbare Kapazität bearbeiten + Ladespannung bearbeiten + Abschaltspannung bearbeiten + Mindesttemperatur bearbeiten + Höchsttemperatur bearbeiten + Batterie-Darstellung + Passe an, wie diese Batterie angezeigt wird + + + Ladeübersicht + Ladegeräte + Spannung + Ladestrom + Ladeleistung + Eingang + Ausgang + Strom + Leistung + Füge deine Ladegeräte hinzu + Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten. + Ladegerät erstellen + + + Name + Eingangsspannung + Ausgangsspannung + Ladestrom + Ladeleistung + Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom. + Stromquelle + Landstrom + Solar + Wind + Generator + Lichtmaschine + Eingangsspannung bearbeiten + Ausgangsspannung bearbeiten + Ladestrom bearbeiten + Ladeleistung bearbeiten + Spannung in Volt (V) eingeben + Strom in Ampere (A) eingeben + Leistung in Watt (W) eingeben + Ladegerät-Darstellung + Passe an, wie dieses Ladegerät angezeigt wird + + + Systemübersicht + Geschätzte Laufzeit + Bei dauerhafter Vollast + Kapazität hinzufügen + Laufzeit-Ziel + Geschätzte Ladezeit + Bei kombinierter Laderate + Ladegeräte hinzufügen + Ladezeit-Ziel + Stückliste + Tippe, um Komponenten zu prüfen + Verbraucher hinzufügen + Ziel %s + Ziel entfernen + Abbrechen + Speichern + Noch keine Verbraucher eingerichtet + Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten. + Ladegeräte + Noch keine Ladegeräte konfiguriert + Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen. + Ladegerät hinzufügen + Vollständiger Bericht (PDF) + + + Tage + Stunden + Minuten + + + Stückliste + Dieses System hat noch keine Komponenten. + PDF exportieren + Stromkabel (rot) + Stromkabel (schwarz) + Sicherung & Halter + Kabelschuhe / Klemmen + Inline-Halter und %dA-Sicherung + Ring- oder Gabelkabelschuhe für %s-Leitungen + Komponenten & Ladegeräte + Hauptverbraucher, Regler und Ladehardware. + Batterien + Hausspeicher und Batteriebänke. + Kabel + Passende Leitungen für jede Strecke. + Sicherungen + Stromkreisschutz und Halter. + Zubehör + Sicherungen, Kabelschuhe und weiteres Montagematerial. + DC Gerät %1$.0fW %2$.0fV + %s rotes Batteriekabel + %s schwarzes Batteriekabel + KFZ Sicherungshalter %dA + %s Kabelschuhe + %1$dAh %2$dV %3$s Batterie + %1$dV %2$dA Batterieladegerät + System-Stückliste + Keine Komponenten verfügbar. + + + Systemübersicht + Geschätzte Laufzeit + Ladezeit + Gesamtleistung + Gesamtstrom + Batteriekapazität + Ladeleistung + Verbraucher + Batterien + Ladegeräte + Spannung + Strom + Leistung + Kabelquerschnitt + Spannungsabfall + Sicherung + Chemie + Spannung + Kapazität + Nutzbare Kapazität + Energie + Eingangsspannung + Ausgangsspannung + Max. Strom + Leistung + + + VoltPlan-Bibliothek + Komponenten suchen + Komponenten konnten nicht geladen werden + Erneut versuchen + Keine Komponenten verfügbar + Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden. + Details folgen in Kürze + + + Einstellungen + Einheiten + Metrisch (mm², m) + Imperial (AWG, ft) + Sicherheitshinweis + Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken. + • 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 + + + Komponente + diff --git a/android/app/src/main/res/values-es/plurals.xml b/android/app/src/main/res/values-es/plurals.xml new file mode 100644 index 0000000..0adf5db --- /dev/null +++ b/android/app/src/main/res/values-es/plurals.xml @@ -0,0 +1,7 @@ + + + + %d componente + %d componentes + + diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..9d0279f --- /dev/null +++ b/android/app/src/main/res/values-es/strings.xml @@ -0,0 +1,263 @@ + + + Cable + + + Añadir + Atrás + Eliminar + + + Sistemas + Aún no hay componentes + Mi sistema + Crea tu primer sistema + Ponle un nombre a tu sistema para que Cable by VoltPlan organice cargas, cableado y recomendaciones en un solo lugar. + Nombre del sistema + Crear sistema + + + Resumen + Componentes + Baterías + Cargadores + + + Editar sistema + Nombre del sistema + Ubicación (opcional) + + + Resumen de cargas + Cargas + Corriente total + Potencia total + Fusible + Cable + Longitud + Biblioteca + Completa la configuración de tus cargas + Añade tu primer componente + Da vida a tu sistema con componentes y deja que Cable by VoltPlan se encargue de recomendar cables y fusibles. + Añadir carga + Explorar biblioteca + + + Editar carga + Nombre de la carga + Vista previa + + + Voltaje + Corriente + Potencia + Longitud del cable (%s) + Vatios + Amperios + + + Configuración avanzada + Ciclo de trabajo + Porcentaje del tiempo activo en el que la carga consume energía. + Tiempo encendido diario + Horas por día que la carga permanece encendida. + h/día + Editar ciclo de trabajo + Introduce el porcentaje de ciclo de trabajo (0-100%). + Editar tiempo encendido diario + Introduce las horas por día que la carga está activa. + + + Revisar componentes + Al tocar verás una lista completa de materiales antes de abrir el enlace de afiliado. Las compras pueden apoyar a VoltPlan. + Al tocar verás una lista completa de materiales con búsquedas de compra para ayudarte a conseguir piezas. + Las compras a través de enlaces de afiliados pueden apoyar a VoltPlan. + + + Banco de baterías + Baterías + Capacidad + Capacidad utilizable + Energía utilizable + Voltaje + Energía + Se detectó un desajuste de voltaje + Se detectó un desajuste de capacidad + Sin baterías todavía + Añadir batería + Añade tu primera batería + Controla la capacidad y la química del banco para mantener tus tiempos de autonomía bajo control. + + + Nombre + Química + Avanzado + Voltaje nominal + Capacidad + Capacidad utilizable (%) + Voltaje de carga + Voltaje de corte + Rango de temperatura + Mínimo + Máximo + Restablecer + Establece el voltaje máximo de carga recomendado. + Establece el voltaje mínimo seguro de descarga. + Define el rango de temperatura de operación recomendado. + Predeterminado %s según la química. + Sobrescritura activa. El valor predeterminado por química sigue siendo %s. + Editar voltaje nominal + Editar capacidad + Editar capacidad utilizable + Editar voltaje de carga + Editar voltaje de corte + Editar temperatura mínima + Editar temperatura máxima + Apariencia de la batería + Personaliza cómo se muestra esta batería + + + Resumen de carga + Cargadores + Voltaje de salida + Tasa de carga + Potencia de carga + Entrada + Salida + Corriente + Potencia + Añade tus cargadores + Lleva el control de los cargadores de costa, los cargadores de alternador y los controladores solares para conocer tu capacidad de carga. + Crear cargador + + + Nombre + Voltaje de entrada + Voltaje de salida + Corriente de carga + Potencia de carga + Déjalo en blanco si no se publica la potencia nominal. La calcularemos a partir del voltaje y la corriente. + Fuente de energía + Corriente de tierra + Solar + Eólica + Generador + Alternador + Editar voltaje de entrada + Editar voltaje de salida + Editar corriente de carga + Editar potencia de carga + Introduce el voltaje en voltios (V) + Introduce la corriente en amperios (A) + Introduce la potencia en vatios (W) + Apariencia del cargador + Personaliza cómo se muestra este cargador + + + Resumen del sistema + Autonomía estimada + Con la carga máxima + Añadir capacidad + Objetivo de autonomía + Tiempo de carga estimado + Con la tasa de carga combinada + Añadir cargadores + Objetivo de carga + Lista de materiales + Pulsa para revisar los componentes + Añadir cargas + Objetivo %s + Eliminar objetivo + Cancelar + Guardar + Aún no hay cargas configuradas + Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema. + Resumen de cargadores + Aún no hay cargadores configurados + Añade cargadores de toma de puerto, DC-DC o solares para conocer tu capacidad de carga. + Añadir cargador + Informe completo (PDF) + + + Días + Horas + Minutos + + + Lista de materiales + Todavía no hay componentes guardados en este sistema. + Exportar PDF + Cable de alimentación (rojo) + Cable de alimentación (negro) + Fusible y portafusibles + Terminales / zapatas + Portafusibles en línea y fusible de %dA + Terminales de anillo o de horquilla para cables de %s + Componentes y cargadores + Dispositivos principales, controladores y equipos de carga. + Baterías + Bancos domésticos y almacenamiento. + Cables + Tendidos dimensionados para cada circuito. + Fusibles + Protección de circuitos y portafusibles. + Accesorios + Fusibles, terminales y piezas de soporte. + dispositivo DC %1$.0fW %2$.0fV + %s cable batería rojo + %s cable batería negro + portafusible en línea %dA + %s terminales de cable + %1$dAh %2$dV %3$s batería + %1$dV %2$dA cargador de batería + Lista de materiales del sistema + No hay componentes disponibles. + + + Resumen del sistema + Autonomía estimada + Tiempo de carga + Potencia total + Corriente total + Capacidad de batería + Potencia de carga + Cargas + Baterías + Cargadores + Tensión + Corriente + Potencia + Sección del cable + Caída de tensión + Fusible + Química + Tensión + Capacidad + Capacidad utilizable + Energía + Tensión de entrada + Tensión de salida + Corriente máx. + Potencia + + + Biblioteca de VoltPlan + Buscar componentes + No se pudieron cargar los componentes + Reintentar + No hay componentes disponibles + Vuelve pronto para encontrar nuevas cargas de VoltPlan. + Detalles próximamente + + + Ajustes + Unidades + Métrico (mm², m) + Imperial (AWG, ft) + Aviso de seguridad + Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación. + • 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 + + + Componente + diff --git a/android/app/src/main/res/values-fr/plurals.xml b/android/app/src/main/res/values-fr/plurals.xml new file mode 100644 index 0000000..77ab81f --- /dev/null +++ b/android/app/src/main/res/values-fr/plurals.xml @@ -0,0 +1,7 @@ + + + + %d composant + %d composants + + diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..5c6b61d --- /dev/null +++ b/android/app/src/main/res/values-fr/strings.xml @@ -0,0 +1,263 @@ + + + Cable + + + Ajouter + Retour + Supprimer + + + Systèmes + Aucun composant pour l\'instant + Mon système + Créez votre premier système + Donnez un nom à votre installation pour que Cable by VoltPlan organise charges, câblage et recommandations au même endroit. + Nom du système + Créer un système + + + Aperçu + Composants + Batteries + Chargeurs + + + Modifier le système + Nom du système + Emplacement (optionnel) + + + Aperçu des charges + Charges + Courant total + Puissance totale + Fusible + Câble + Longueur + Bibliothèque + Terminez la configuration de vos charges + Ajoutez votre premier composant + Donnez vie à votre système avec des composants et laissez Cable by VoltPlan recommander câbles et fusibles. + Ajouter une charge + Parcourir la bibliothèque + + + Modifier la charge + Nom de la charge + Aperçu + + + Tension + Courant + Puissance + Longueur du câble (%s) + Watts + Ampères + + + Paramètres avancés + Facteur de marche + Pourcentage du temps actif pendant lequel la charge consomme réellement de l\'énergie. + Temps de fonctionnement quotidien + Heures par jour pendant lesquelles la charge est allumée. + h/jour + Modifier le facteur de marche + Saisissez le facteur de marche en pourcentage (0-100%). + Modifier le temps de fonctionnement quotidien + Saisissez le nombre d\'heures par jour pendant lesquelles la charge est active. + + + Examiner les composants + En appuyant, vous verrez une liste complète de matériel avant d\'ouvrir le lien d\'affiliation. Les achats peuvent soutenir VoltPlan. + En appuyant, vous verrez une liste complète de matériel avec des recherches d\'achat pour vous aider à trouver les pièces. + Les achats effectués via des liens d\'affiliation peuvent soutenir VoltPlan. + + + Banque de batteries + Batteries + Capacité + Capacité utilisable + Énergie utilisable + Tension + Énergie + Écart de tension détecté + Écart de capacité détecté + Aucune batterie pour l\'instant + Ajouter une batterie + Ajoutez votre première batterie + Suivez la capacité et la chimie de votre banc pour mieux maîtriser l\'autonomie. + + + Nom + Chimie + Avancé + Tension nominale + Capacité + Capacité utilisable (%) + Tension de charge + Tension de coupure + Plage de température + Minimum + Maximum + Réinitialiser + Définissez la tension de charge maximale recommandée. + Définissez la tension minimale de décharge sûre. + Définissez la plage de température de fonctionnement recommandée. + Par défaut %s selon la chimie. + Remplacement actif. La valeur par défaut liée à la chimie reste %s. + Modifier la tension nominale + Modifier la capacité + Modifier la capacité utilisable + Modifier la tension de charge + Modifier la tension de coupure + Modifier la température minimale + Modifier la température maximale + Apparence de la batterie + Personnalisez l\'affichage de cette batterie + + + Aperçu de charge + Chargeurs + Tension de sortie + Courant de charge + Puissance de charge + Entrée + Sortie + Courant + Puissance + Ajoutez vos chargeurs + Suivez l\'alimentation secteur, les chargeurs d\'alternateur et les régulateurs solaires pour connaître votre capacité de charge. + Créer un chargeur + + + Nom + Tension d\'entrée + Tension de sortie + Courant de charge + Puissance de charge + Laissez vide si la puissance nominale n\'est pas indiquée. Nous la calculerons à partir de la tension et du courant. + Source d\'énergie + Courant de quai + Solaire + Éolienne + Groupe électrogène + Alternateur + Modifier la tension d\'entrée + Modifier la tension de sortie + Modifier le courant de charge + Modifier la puissance de charge + Saisissez la tension en volts (V) + Saisissez le courant en ampères (A) + Saisissez la puissance en watts (W) + Apparence du chargeur + Personnalisez l\'affichage de ce chargeur + + + Aperçu du système + Autonomie estimée + À charge maximale + Ajouter capacité + Objectif d\'autonomie + Temps de charge estimé + Au débit de charge combiné + Ajouter des chargeurs + Objectif de recharge + Liste de matériel + Touchez pour consulter les composants + Ajouter des charges + Objectif %s + Supprimer l\'objectif + Annuler + Enregistrer + Aucune charge configurée pour l\'instant + Ajoutez des composants pour obtenir des recommandations de câbles et de fusibles adaptées à ce système. + Vue d\'ensemble des chargeurs + Aucun chargeur configuré pour l\'instant + Ajoutez des chargeurs secteur, DC-DC ou solaires pour comprendre votre capacité de charge. + Ajouter un chargeur + Rapport complet (PDF) + + + Jours + Heures + Minutes + + + Liste de matériel + Aucun composant enregistré pour ce système pour l\'instant. + Exporter en PDF + Câble d\'alimentation (rouge) + Câble d\'alimentation (noir) + Fusible & porte-fusible + Cosses / bornes + Porte-fusible en ligne et fusible de %dA + Cosses à œillet ou à fourche adaptées aux câbles de %s + Composants & chargeurs + Appareils principaux, contrôleurs et équipements de charge. + Batteries + Banques domestiques et stockage. + Câbles + Liaisons dimensionnées pour chaque circuit. + Fusibles + Protection des circuits et porte-fusibles. + Accessoires + Fusibles, cosses et pièces complémentaires. + appareil DC %1$.0fW %2$.0fV + %s câble batterie rouge + %s câble batterie noir + porte-fusible %dA + %s cosses de câble + %1$dAh %2$dV %3$s batterie + %1$dV %2$dA chargeur de batterie + Liste de matériaux du système + Aucun composant disponible. + + + Résumé du système + Autonomie estimée + Temps de charge + Puissance totale + Courant total + Capacité de batterie + Puissance de charge + Charges + Batteries + Chargeurs + Tension + Courant + Puissance + Section du câble + Chute de tension + Fusible + Chimie + Tension + Capacité + Capacité utilisable + Énergie + Tension d\'entrée + Tension de sortie + Courant max. + Puissance + + + Bibliothèque VoltPlan + Rechercher des composants + Impossible de charger les composants + Réessayer + Aucun composant disponible + Revenez bientôt pour découvrir de nouvelles charges VoltPlan. + Détails à venir + + + Réglages + Unités + Métrique (mm², m) + Impérial (AWG, ft) + Avertissement de sécurité + Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d\'estimation. + • 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 + + + Composant + diff --git a/android/app/src/main/res/values-nl/plurals.xml b/android/app/src/main/res/values-nl/plurals.xml new file mode 100644 index 0000000..dc0d16c --- /dev/null +++ b/android/app/src/main/res/values-nl/plurals.xml @@ -0,0 +1,7 @@ + + + + %d component + %d componenten + + diff --git a/android/app/src/main/res/values-nl/strings.xml b/android/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..1e18d86 --- /dev/null +++ b/android/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,263 @@ + + + Cable + + + Toevoegen + Terug + Verwijderen + + + Systemen + Nog geen componenten + Mijn systeem + Maak je eerste systeem + Geef je installatie een naam zodat Cable by VoltPlan lasten, bekabeling en aanbevelingen op één plek kan organiseren. + Systeemnaam + Systeem maken + + + Overzicht + Componenten + Batterijen + Laders + + + Systeem bewerken + Naam van het systeem + Locatie (optioneel) + + + Lastenoverzicht + Lasten + Totale stroom + Totaal vermogen + Zekering + Kabel + Lengte + Bibliotheek + Rond de configuratie van je lasten af + Voeg je eerste component toe + Breng je systeem tot leven met componenten en laat Cable by VoltPlan de kabel- en zekeringadviezen verzorgen. + Last toevoegen + Bibliotheek bekijken + + + Last bewerken + Naam van de last + Voorbeeld + + + Spanning + Stroom + Vermogen + Kabellengte (%s) + Watt + Ampère + + + Geavanceerde instellingen + Inschakelduur + Percentage van elke actieve sessie waarin de last daadwerkelijk vermogen vraagt. + Dagelijkse aan-tijd + Uren per dag dat de last is ingeschakeld. + u/dag + Inschakelduur bewerken + Voer de inschakelduur in als percentage (0-100%). + Dagelijkse aan-tijd bewerken + Voer het aantal uren per dag in dat de last actief is. + + + Onderdelen bekijken + Tik hierboven om een volledige materiaallijst te zien voordat de affiliate-link wordt geopend. Aankopen kunnen VoltPlan ondersteunen. + Tik hierboven om een volledige materiaallijst te zien met aankoopzoekopdrachten die je helpen onderdelen te vinden. + Aankopen via affiliate-links kunnen VoltPlan ondersteunen. + + + Accubank + Batterijen + Capaciteit + Beschikbare capaciteit + Beschikbare energie + Spanning + Energie + Spanningsafwijking gedetecteerd + Capaciteitsafwijking gedetecteerd + Nog geen batterijen + Accu toevoegen + Voeg je eerste accu toe + Houd capaciteit en chemie van de accubank in de gaten om de gebruiksduur te beheersen. + + + Naam + Chemie + Geavanceerd + Nominale spanning + Capaciteit + Beschikbare capaciteit (%) + Laadspanning + Afsluitspanning + Temperatuurbereik + Minimum + Maximum + Resetten + Stel de maximaal aanbevolen laadspanning in. + Stel de minimale veilige ontlaadspanning in. + Bepaal het aanbevolen temperatuurbereik voor gebruik. + Standaard %s op basis van de chemie. + Override actief. Chemische standaard blijft %s. + Nominale spanning bewerken + Capaciteit bewerken + Beschikbare capaciteit bewerken + Laadspanning bewerken + Afsluitspanning bewerken + Minimale temperatuur bewerken + Maximale temperatuur bewerken + Uiterlijk van accu + Bepaal hoe deze accu wordt weergegeven + + + Laadoverzicht + Laders + Uitgangsspanning + Laadstroom + Laadvermogen + Ingang + Uitgang + Stroom + Vermogen + Voeg je laders toe + Houd walstroomvoedingen, dynamoladers en zonne-regelaars bij om je laadcapaciteit te begrijpen. + Lader aanmaken + + + Naam + Ingangsspanning + Uitgangsspanning + Laadstroom + Laadvermogen + Laat leeg als het opgegeven vermogen ontbreekt. We berekenen het uit spanning en stroom. + Stroombron + Walstroom + Zonne-energie + Wind + Generator + Dynamo + Ingangsspanning bewerken + Uitgangsspanning bewerken + Laadstroom bewerken + Laadvermogen bewerken + Voer de spanning in volt (V) in + Voer de stroom in ampère (A) in + Voer het vermogen in watt (W) in + Uiterlijk van lader + Bepaal hoe deze lader wordt weergegeven + + + Systeemoverzicht + Geschatte looptijd + Bij maximale belasting + Capaciteit toevoegen + Looptijddoel + Geschatte laadtijd + Met gecombineerde laadsnelheid + Laders toevoegen + Laadtijddoel + Stuklijst + Tik om componenten te bekijken + Lasten toevoegen + Doel %s + Doel verwijderen + Annuleren + Opslaan + Nog geen lasten geconfigureerd + Voeg componenten toe om kabel- en zekeringadviezen te krijgen die zijn afgestemd op dit systeem. + Overzicht van laders + Nog geen laders geconfigureerd + Voeg walstroom-, DC-DC- of zonneladers toe om je laadcapaciteit te begrijpen. + Lader toevoegen + Volledig rapport (PDF) + + + Dagen + Uren + Minuten + + + Materiaallijst + Er zijn nog geen componenten voor dit systeem opgeslagen. + PDF exporteren + Voedingskabel (rood) + Voedingskabel (zwart) + Zekering & houder + Kabelschoenen / klemmen + In-line houder en zekering van %dA + Ring- of vorkklemmen geschikt voor %s-bekabeling + Componenten & laders + Hoofdapparaten, regelaars en laadapparatuur. + Batterijen + Huishoudbanken en opslag. + Kabels + Op maat gemaakte stroomtrajecten per circuit. + Zekeringen + Circuitbeveiliging en houders. + Accessoires + Zekeringen, kabelschoenen en ondersteunende onderdelen. + DC apparaat %1$.0fW %2$.0fV + %s rode accukabel + %s zwarte accukabel + inline zekeringhouder %dA + %s kabelschoenen + %1$dAh %2$dV %3$s batterij + %1$dV %2$dA acculader + Stuklijst van het systeem + Geen componenten beschikbaar. + + + Systeemoverzicht + Geschatte looptijd + Laadtijd + Totaal vermogen + Totale stroom + Accucapaciteit + Laadvermogen + Verbruikers + Accu\'s + Laders + Spanning + Stroom + Vermogen + Kabeldoorsnede + Spanningsval + Zekering + Chemie + Spanning + Capaciteit + Bruikbare capaciteit + Energie + Ingangsspanning + Uitgangsspanning + Max. stroom + Vermogen + + + VoltPlan-bibliotheek + Componenten zoeken + Componenten konden niet worden geladen + Opnieuw + Geen componenten beschikbaar + Kom binnenkort terug voor nieuwe lasten van VoltPlan. + Details volgen binnenkort + + + Instellingen + Eenheden + Metrisch (mm², m) + Imperiaal (AWG, ft) + Veiligheidswaarschuwing + Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen. + • 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 + + + Component + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0cf1987 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #519098 + diff --git a/android/app/src/main/res/values/plurals.xml b/android/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000..7f7ed40 --- /dev/null +++ b/android/app/src/main/res/values/plurals.xml @@ -0,0 +1,7 @@ + + + + %d component + %d components + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..798cb44 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,263 @@ + + + Cable + + + Add + Back + Delete + + + Systems + No components yet + My System + Create your first system + Give your setup a name so Cable by VoltPlan can organize loads, wiring, and recommendations in one place. + System Name + Create System + + + Overview + Components + Batteries + Chargers + + + Edit System + System name + Location (optional) + + + Load Overview + Loads + Total Current + Total Power + Fuse + Cable + Length + Library + Finish configuring your loads + Add your first component + Bring your system to life with components and let Cable by VoltPlan handle cable and fuse recommendations. + Add Load + Browse Library + + + Edit Load + Load name + Preview + + + Voltage + Current + Power + Cable Length (%s) + Watt + Ampere + + + Advanced Settings + Duty Cycle + Percentage of each active session where the load actually draws power. + Daily On-Time + Hours per day the load is turned on. + h/day + Edit Duty Cycle + Enter duty cycle as a percentage (0-100%). + Edit Daily On-Time + Enter the number of hours per day the load is active. + + + Review parts + Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan. + Tapping above shows a full bill of materials with shopping searches to help you source parts. + Purchases through affiliate links may support VoltPlan. + + + Battery Bank + Batteries + Capacity + Usable Capacity + Usable Energy + Voltage + Energy + Voltage mismatch detected + Capacity mismatch detected + No Batteries Yet + Add Battery + Add your first battery + Track your bank\'s capacity and chemistry to keep runtime expectations in check. + + + Name + Chemistry + Advanced + Nominal Voltage + Capacity + Usable Capacity (%) + Charge Voltage + Cut-off Voltage + Temperature Range + Minimum + Maximum + Reset + Set the maximum recommended charging voltage. + Set the minimum safe discharge voltage. + Define the recommended operating temperature range. + Defaults to %s based on chemistry. + Override active. Chemistry default remains %s. + Edit Nominal Voltage + Edit Capacity + Edit Usable Capacity + Edit Charge Voltage + Edit Cut-off Voltage + Edit Minimum Temperature + Edit Maximum Temperature + Battery Appearance + Customize how this battery shows up + + + Charging Overview + Chargers + Output Voltage + Charge Rate + Charge Power + Input + Output + Current + Power + Add your chargers + Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity. + Create Charger + + + Name + Input Voltage + Output Voltage + Charge Current + Charge Power + Leave blank when the rated wattage isn\'t published. We\'ll calculate it from voltage and current. + Power Source + Shore Power + Solar + Wind + Generator + Alternator + Edit Input Voltage + Edit Output Voltage + Edit Charge Current + Edit Charge Power + Enter voltage in volts (V) + Enter current in amps (A) + Enter power in watts (W) + Charger Appearance + Customize how this charger shows up + + + System Overview + Estimated runtime + At maximum load draw + Add capacity + Runtime Goal + Estimated charge time + At combined charge rate + Add chargers + Charge Goal + Bill of Materials + Tap to review components + Add loads + Goal %s + Remove Goal + Cancel + Save + No loads configured yet + Add components to get cable sizing and fuse recommendations tailored to this system. + Charger Overview + No chargers configured yet + Add shore power, DC-DC, or solar chargers to understand your charging capacity. + Add Charger + Full Report (PDF) + + + Days + Hours + Minutes + + + Bill of Materials + No components saved in this system yet. + Export PDF + Power Cable (Red) + Power Cable (Black) + Fuse & Holder + Cable Shoes / Terminals + Inline holder and %dA fuse + Ring or spade terminals sized for %s wiring + Components & Chargers + Primary devices, controllers, and charging gear. + Batteries + House banks and storage. + Cables + Sized power runs for every circuit. + Fuses + Circuit protection and holders. + Accessories + Fuses, lugs, and supporting hardware. + DC device %1$.0fW %2$.0fV + %s red battery cable + %s black battery cable + inline fuse holder %dA + %s cable shoes + %1$dAh %2$dV %3$s battery + %1$dV %2$dA battery charger + System Bill of Materials + No components available. + + + System Summary + Estimated Runtime + Charge Time + Total Power + Total Current + Battery Capacity + Charger Power + Loads + Batteries + Chargers + Voltage + Current + Power + Cable Size + Voltage Drop + Fuse + Chemistry + Voltage + Capacity + Usable Capacity + Energy + Input Voltage + Output Voltage + Max Current + Power + + + VoltPlan Library + Search components + Unable to load components + Retry + No components available + Check back soon for new loads from VoltPlan. + Details coming soon + + + Settings + Units + Metric (mm², m) + Imperial (AWG, ft) + Safety Disclaimer + This application provides electrical calculations for educational and estimation purposes only. + • 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 + + + Component + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..d322166 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +