Compare commits
3 Commits
89ee36c1a4
...
022e309873
| Author | SHA1 | Date | |
|---|---|---|---|
| 022e309873 | |||
| 01cdaf1861 | |||
| d4b490ea07 |
@@ -379,6 +379,11 @@
|
|||||||
"charger.source.generator" = "Generator";
|
"charger.source.generator" = "Generator";
|
||||||
"charger.source.alternator" = "Alternator";
|
"charger.source.alternator" = "Alternator";
|
||||||
|
|
||||||
|
// MARK: - System Deletion
|
||||||
|
"systems.delete.confirm.title" = "Delete System?";
|
||||||
|
"systems.delete.confirm.message" = "This will permanently delete the system and all its components.";
|
||||||
|
"systems.delete.confirm.button" = "Delete";
|
||||||
|
|
||||||
// MARK: - Share Menu
|
// MARK: - Share Menu
|
||||||
"overview.share.diagram" = "Wiring Diagram";
|
"overview.share.diagram" = "Wiring Diagram";
|
||||||
"overview.share.pdf" = "Full Report (PDF)";
|
"overview.share.pdf" = "Full Report (PDF)";
|
||||||
|
|||||||
@@ -219,7 +219,10 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sheet(item: $overviewShareItem, onDismiss: cleanupOverviewShareItem) { item in
|
.sheet(item: $overviewShareItem, onDismiss: {
|
||||||
|
cleanupOverviewShareItem()
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
|
}) { item in
|
||||||
ShareSheet(items: item.shareItems)
|
ShareSheet(items: item.shareItems)
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
@@ -1133,7 +1136,6 @@ struct LoadsView: View {
|
|||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||||
isExportingOverview = false
|
isExportingOverview = false
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
@@ -1163,7 +1165,6 @@ struct LoadsView: View {
|
|||||||
"system": snapshot.systemName,
|
"system": snapshot.systemName,
|
||||||
])
|
])
|
||||||
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
|
||||||
} else {
|
} else {
|
||||||
overviewExportError = OverviewExportError(
|
overviewExportError = OverviewExportError(
|
||||||
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
message: String(localized: "overview.share.diagram.error", defaultValue: "Could not generate diagram. Check your internet connection.")
|
||||||
|
|||||||
@@ -301,7 +301,10 @@ struct SystemBillOfMaterialsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("system-bom-view")
|
.accessibilityIdentifier("system-bom-view")
|
||||||
.sheet(item: $activeShareItem, onDismiss: cleanupShareItem) { item in
|
.sheet(item: $activeShareItem, onDismiss: {
|
||||||
|
cleanupShareItem()
|
||||||
|
ReviewPrompt.registerSuccessfulExport()
|
||||||
|
}) { item in
|
||||||
ShareSheet(items: [item.url])
|
ShareSheet(items: [item.url])
|
||||||
}
|
}
|
||||||
.alert(item: $exportError) { error in
|
.alert(item: $exportError) { error in
|
||||||
@@ -346,7 +349,6 @@ struct SystemBillOfMaterialsView: View {
|
|||||||
)
|
)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
activeShareItem = ExportedPDFShareItem(url: url)
|
activeShareItem = ExportedPDFShareItem(url: url)
|
||||||
ReviewPrompt.registerSuccessfulExport()
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct SystemsView: View {
|
|||||||
@State private var showingComponentLibrary = false
|
@State private var showingComponentLibrary = false
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var hasPerformedInitialAutoNavigation = false
|
@State private var hasPerformedInitialAutoNavigation = false
|
||||||
|
@State private var systemsPendingDeletion: [ElectricalSystem]?
|
||||||
|
|
||||||
private let systemColorOptions = [
|
private let systemColorOptions = [
|
||||||
"blue", "green", "orange", "red", "purple", "yellow",
|
"blue", "green", "orange", "red", "purple", "yellow",
|
||||||
@@ -87,7 +88,9 @@ struct SystemsView: View {
|
|||||||
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
|
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
|
||||||
.accessibilityAddTraits(.isButton)
|
.accessibilityAddTraits(.isButton)
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteSystems)
|
.onDelete { offsets in
|
||||||
|
systemsPendingDeletion = offsets.map { systems[$0] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("systems-list")
|
.accessibilityIdentifier("systems-list")
|
||||||
}
|
}
|
||||||
@@ -133,6 +136,28 @@ struct SystemsView: View {
|
|||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(unitSettings)
|
.environmentObject(unitSettings)
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
String(localized: "systems.delete.confirm.title", defaultValue: "Delete System?"),
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { systemsPendingDeletion != nil },
|
||||||
|
set: { if !$0 { systemsPendingDeletion = nil } }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
String(localized: "systems.delete.confirm.button", defaultValue: "Delete"),
|
||||||
|
role: .destructive
|
||||||
|
) {
|
||||||
|
if let pending = systemsPendingDeletion {
|
||||||
|
deleteSystems(pending)
|
||||||
|
systemsPendingDeletion = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button(String(localized: "battery.editor.alert.cancel", defaultValue: "Cancel"), role: .cancel) {
|
||||||
|
systemsPendingDeletion = nil
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(String(localized: "systems.delete.confirm.message", defaultValue: "This will permanently delete the system and all its components."))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var systemsEmptyState: some View {
|
private var systemsEmptyState: some View {
|
||||||
@@ -416,8 +441,7 @@ struct SystemsView: View {
|
|||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteSystems(offsets: IndexSet) {
|
private func deleteSystems(_ systemsToDelete: [ElectricalSystem]) {
|
||||||
let systemsToDelete = offsets.map { systems[$0] }
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for system in systemsToDelete {
|
for system in systemsToDelete {
|
||||||
AnalyticsTracker.log(
|
AnalyticsTracker.log(
|
||||||
|
|||||||
@@ -444,6 +444,11 @@
|
|||||||
"charger.source.generator" = "Generator";
|
"charger.source.generator" = "Generator";
|
||||||
"charger.source.alternator" = "Lichtmaschine";
|
"charger.source.alternator" = "Lichtmaschine";
|
||||||
|
|
||||||
|
// MARK: - System Deletion
|
||||||
|
"systems.delete.confirm.title" = "System löschen?";
|
||||||
|
"systems.delete.confirm.message" = "Das System und alle seine Komponenten werden dauerhaft gelöscht.";
|
||||||
|
"systems.delete.confirm.button" = "Löschen";
|
||||||
|
|
||||||
// MARK: - Share Menu
|
// MARK: - Share Menu
|
||||||
"overview.share.diagram" = "Schaltplan";
|
"overview.share.diagram" = "Schaltplan";
|
||||||
"overview.share.pdf" = "Vollständiger Bericht (PDF)";
|
"overview.share.pdf" = "Vollständiger Bericht (PDF)";
|
||||||
|
|||||||
@@ -406,6 +406,11 @@
|
|||||||
"charger.source.generator" = "Generador";
|
"charger.source.generator" = "Generador";
|
||||||
"charger.source.alternator" = "Alternador";
|
"charger.source.alternator" = "Alternador";
|
||||||
|
|
||||||
|
// MARK: - System Deletion
|
||||||
|
"systems.delete.confirm.title" = "¿Eliminar sistema?";
|
||||||
|
"systems.delete.confirm.message" = "Esto eliminará permanentemente el sistema y todos sus componentes.";
|
||||||
|
"systems.delete.confirm.button" = "Eliminar";
|
||||||
|
|
||||||
// MARK: - Share Menu
|
// MARK: - Share Menu
|
||||||
"overview.share.diagram" = "Diagrama de cableado";
|
"overview.share.diagram" = "Diagrama de cableado";
|
||||||
"overview.share.pdf" = "Informe completo (PDF)";
|
"overview.share.pdf" = "Informe completo (PDF)";
|
||||||
|
|||||||
@@ -406,6 +406,11 @@
|
|||||||
"charger.source.generator" = "Groupe électrogène";
|
"charger.source.generator" = "Groupe électrogène";
|
||||||
"charger.source.alternator" = "Alternateur";
|
"charger.source.alternator" = "Alternateur";
|
||||||
|
|
||||||
|
// MARK: - System Deletion
|
||||||
|
"systems.delete.confirm.title" = "Supprimer le système ?";
|
||||||
|
"systems.delete.confirm.message" = "Cela supprimera définitivement le système et tous ses composants.";
|
||||||
|
"systems.delete.confirm.button" = "Supprimer";
|
||||||
|
|
||||||
// MARK: - Share Menu
|
// MARK: - Share Menu
|
||||||
"overview.share.diagram" = "Schéma de câblage";
|
"overview.share.diagram" = "Schéma de câblage";
|
||||||
"overview.share.pdf" = "Rapport complet (PDF)";
|
"overview.share.pdf" = "Rapport complet (PDF)";
|
||||||
|
|||||||
@@ -406,6 +406,11 @@
|
|||||||
"charger.source.generator" = "Generator";
|
"charger.source.generator" = "Generator";
|
||||||
"charger.source.alternator" = "Dynamo";
|
"charger.source.alternator" = "Dynamo";
|
||||||
|
|
||||||
|
// MARK: - System Deletion
|
||||||
|
"systems.delete.confirm.title" = "Systeem verwijderen?";
|
||||||
|
"systems.delete.confirm.message" = "Dit verwijdert het systeem en alle bijbehorende componenten permanent.";
|
||||||
|
"systems.delete.confirm.button" = "Verwijderen";
|
||||||
|
|
||||||
// MARK: - Share Menu
|
// MARK: - Share Menu
|
||||||
"overview.share.diagram" = "Bedradingsschema";
|
"overview.share.diagram" = "Bedradingsschema";
|
||||||
"overview.share.pdf" = "Volledig rapport (PDF)";
|
"overview.share.pdf" = "Volledig rapport (PDF)";
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ android {
|
|||||||
applicationId = "app.voltplan.cable"
|
applicationId = "app.voltplan.cable"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 85
|
||||||
versionName = "1.0"
|
versionName = "1.7.0"
|
||||||
|
|
||||||
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
|
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
|
||||||
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
|
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")
|
||||||
|
|||||||
2
android/app/proguard-rules.pro
vendored
2
android/app/proguard-rules.pro
vendored
@@ -10,3 +10,5 @@
|
|||||||
# Retrofit
|
# Retrofit
|
||||||
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
|
||||||
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
-keep,allowobfuscation,allowshrinking class retrofit2.Response
|
||||||
|
# Play Review KTX
|
||||||
|
-dontwarn com.google.android.gms.common.annotation.NoNullnessRewrite
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import app.voltplan.cable.BuildConfig
|
import app.voltplan.cable.BuildConfig
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import com.google.android.play.core.ktx.launchReview
|
import com.google.android.play.core.ktx.launchReview
|
||||||
import com.google.android.play.core.ktx.requestReview
|
import com.google.android.play.core.ktx.requestReview
|
||||||
import com.google.android.play.core.review.ReviewManagerFactory
|
import com.google.android.play.core.review.ReviewManagerFactory
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +116,11 @@ object ReviewPrompt {
|
|||||||
runCatching {
|
runCatching {
|
||||||
val manager = ReviewManagerFactory.create(context)
|
val manager = ReviewManagerFactory.create(context)
|
||||||
val reviewInfo = manager.requestReview()
|
val reviewInfo = manager.requestReview()
|
||||||
|
// Wait until the activity is resumed so the dialog doesn't overlap a share sheet
|
||||||
|
// that was just launched (startActivity returns immediately, so we may still be paused).
|
||||||
|
(activity as? LifecycleOwner)?.lifecycle?.currentStateFlow
|
||||||
|
?.filter { state: Lifecycle.State -> state.isAtLeast(Lifecycle.State.RESUMED) }
|
||||||
|
?.first()
|
||||||
manager.launchReview(activity, reviewInfo)
|
manager.launchReview(activity, reviewInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package app.voltplan.cable.data
|
package app.voltplan.cable.data
|
||||||
|
|
||||||
|
import android.icu.util.LocaleData
|
||||||
|
import android.icu.util.ULocale
|
||||||
|
import android.os.Build
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
/** Metric (mm², m) or imperial (AWG, ft). Mirrors the iOS `UnitSystem` enum. */
|
/** Metric (mm², m) or imperial (AWG, ft). Mirrors the iOS `UnitSystem` enum. */
|
||||||
@@ -13,10 +16,16 @@ enum class UnitSystem(val rawValue: String) {
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromRaw(raw: String?): UnitSystem? = entries.firstOrNull { it.rawValue == raw }
|
fun fromRaw(raw: String?): UnitSystem? = entries.firstOrNull { it.rawValue == raw }
|
||||||
|
|
||||||
/** Default for the device locale — US measurement system implies imperial. */
|
/** Default for the device locale — mirrors iOS `Locale.measurementSystem == .us`. */
|
||||||
fun deviceDefault(): UnitSystem {
|
fun deviceDefault(): UnitSystem {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
val system = runCatching { LocaleData.getMeasurementSystem(ULocale.getDefault()) }.getOrNull()
|
||||||
|
if (system != null) {
|
||||||
|
return if (system == LocaleData.MeasurementSystem.US) IMPERIAL else METRIC
|
||||||
|
}
|
||||||
|
}
|
||||||
val country = Locale.getDefault().country.uppercase()
|
val country = Locale.getDefault().country.uppercase()
|
||||||
// Countries that customarily use US/imperial measurements.
|
// Fallback: countries that customarily use US/imperial measurements.
|
||||||
return if (country in setOf("US", "LR", "MM")) IMPERIAL else METRIC
|
return if (country in setOf("US", "LR", "MM")) IMPERIAL else METRIC
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable.data.model
|
|||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import app.voltplan.cable.data.LocaleDefaults
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,7 +79,7 @@ data class SavedBattery(
|
|||||||
data class SavedCharger(
|
data class SavedCharger(
|
||||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val inputVoltage: Double = 230.0,
|
val inputVoltage: Double = LocaleDefaults.mainsVoltage,
|
||||||
val outputVoltage: Double = 14.2,
|
val outputVoltage: Double = 14.2,
|
||||||
val maxCurrentAmps: Double = 30.0,
|
val maxCurrentAmps: Double = 30.0,
|
||||||
val maxPowerWatts: Double = 0.0,
|
val maxPowerWatts: Double = 0.0,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable.library
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.model.Chemistry
|
import app.voltplan.cable.data.model.Chemistry
|
||||||
import app.voltplan.cable.data.model.ElectricalSystem
|
import app.voltplan.cable.data.model.ElectricalSystem
|
||||||
@@ -55,10 +56,14 @@ class ComponentLibraryViewModel(
|
|||||||
fun refresh() = load()
|
fun refresh() = load()
|
||||||
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
|
fun setQuery(q: String) { _state.value = _state.value.copy(query = q) }
|
||||||
|
|
||||||
|
/** Resolves the display name of [systemId], or null when unknown. */
|
||||||
|
suspend fun systemName(systemId: String?): String? =
|
||||||
|
systemId?.let { repo.getSystem(it)?.name }
|
||||||
|
|
||||||
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */
|
/** Returns the system to add into, creating a new one when [targetSystemId] is null. */
|
||||||
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
|
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
|
||||||
if (targetSystemId != null) return targetSystemId to false
|
if (targetSystemId != null) return targetSystemId to false
|
||||||
val name = repo.uniqueSystemName("New System")
|
val name = repo.uniqueSystemName(app.getString(R.string.default_system_new))
|
||||||
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
|
val system = ElectricalSystem(name = name, iconName = SystemIconMapper.iconFor(name), colorName = SystemIconMapper.colorOptions.random())
|
||||||
repo.upsertSystem(system)
|
repo.upsertSystem(system)
|
||||||
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
|
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
|
||||||
@@ -95,7 +100,7 @@ class ComponentLibraryViewModel(
|
|||||||
affiliateCountryCode = affiliate?.country,
|
affiliateCountryCode = affiliate?.country,
|
||||||
)
|
)
|
||||||
repo.upsertLoad(load)
|
repo.upsertLoad(load)
|
||||||
Analytics.log("Library Load Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
|
Analytics.log("Library Load Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to (systemName(systemId) ?: "")))
|
||||||
|
|
||||||
onDone(if (createdNewSystem) systemId else null)
|
onDone(if (createdNewSystem) systemId else null)
|
||||||
}
|
}
|
||||||
@@ -123,7 +128,7 @@ class ComponentLibraryViewModel(
|
|||||||
affiliateCountryCode = affiliate?.country,
|
affiliateCountryCode = affiliate?.country,
|
||||||
)
|
)
|
||||||
repo.upsertBattery(battery)
|
repo.upsertBattery(battery)
|
||||||
Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
|
Analytics.log("Library Battery Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to (systemName(systemId) ?: "")))
|
||||||
|
|
||||||
onDone(systemId, battery.id)
|
onDone(systemId, battery.id)
|
||||||
}
|
}
|
||||||
@@ -157,7 +162,7 @@ class ComponentLibraryViewModel(
|
|||||||
powerSourceType = sourceType.rawValue,
|
powerSourceType = sourceType.rawValue,
|
||||||
)
|
)
|
||||||
repo.upsertCharger(charger)
|
repo.upsertCharger(charger)
|
||||||
Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to systemId))
|
Analytics.log("Library Charger Added", mapOf("id" to item.id, "name" to item.localizedName, "system" to (systemName(systemId) ?: "")))
|
||||||
|
|
||||||
onDone(systemId, charger.id)
|
onDone(systemId, charger.id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import android.graphics.Bitmap
|
|||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.Typeface
|
||||||
import app.voltplan.cable.R
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.model.effectivePowerWatts
|
import app.voltplan.cable.data.model.effectivePowerWatts
|
||||||
@@ -12,6 +16,7 @@ import app.voltplan.cable.data.model.energyWattHours
|
|||||||
import app.voltplan.cable.data.model.sourceType
|
import app.voltplan.cable.data.model.sourceType
|
||||||
import app.voltplan.cable.data.UnitSystem
|
import app.voltplan.cable.data.UnitSystem
|
||||||
import app.voltplan.cable.ui.system.DetailState
|
import app.voltplan.cable.ui.system.DetailState
|
||||||
|
import app.voltplan.cable.util.Fmt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -25,13 +30,12 @@ import okhttp3.OkHttpClient
|
|||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the system wiring diagram PNG from the VoltPlan diagram API — the same endpoint and
|
|
||||||
* payload the iOS app uses (`SystemOverviewPDFExporter.fetchDiagramImage`). Used both for the
|
|
||||||
* standalone "Wiring Diagram" image export and the diagram page embedded in the overview PDF.
|
|
||||||
*/
|
|
||||||
object SystemDiagram {
|
object SystemDiagram {
|
||||||
private const val ENDPOINT = "https://voltplan.app/api/diagram/generate"
|
private const val ENDPOINT = "https://voltplan.app/api/diagram/generate"
|
||||||
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
|
||||||
@@ -39,14 +43,29 @@ object SystemDiagram {
|
|||||||
.callTimeout(15, TimeUnit.SECONDS)
|
.callTimeout(15, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
/** Fetches the diagram as a [Bitmap], or null on any network/decoding failure. */
|
// Fallback bitmap dimensions and layout (mirrors iOS drawSystemDiagram)
|
||||||
|
private const val FB_W = 1200
|
||||||
|
private const val FB_H = 750
|
||||||
|
private const val FB_MARGIN = 48f
|
||||||
|
private const val FB_BLOCK_H = 100f
|
||||||
|
private const val FB_BLOCK_SPACING = 14f
|
||||||
|
private const val FB_BLOCK_INSET = 24f
|
||||||
|
private const val FB_MAX_BLOCKS = 4
|
||||||
|
private const val FB_BATT_MIN_H = 260f
|
||||||
|
private val FB_CHARGER_COLOR = Color.rgb(51, 140, 222)
|
||||||
|
private val FB_BATTERY_COLOR = Color.rgb(77, 176, 79)
|
||||||
|
private val FB_LOAD_COLOR = Color.rgb(237, 140, 36)
|
||||||
|
private val FB_CARD_BG = Color.rgb(245, 245, 247)
|
||||||
|
|
||||||
|
/** Fetches the wiring diagram PNG from the VoltPlan API; returns null on any failure. */
|
||||||
suspend fun fetch(state: DetailState, unit: UnitSystem): Bitmap? = withContext(Dispatchers.IO) {
|
suspend fun fetch(state: DetailState, unit: UnitSystem): Bitmap? = withContext(Dispatchers.IO) {
|
||||||
val payload = buildPayload(state, unit)
|
val payload = buildPayload(state, unit)
|
||||||
|
val body = Json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA)
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(ENDPOINT)
|
.url(ENDPOINT)
|
||||||
.addHeader("Content-Type", "application/json")
|
.addHeader("Content-Type", "application/json")
|
||||||
.addHeader("Accept", "image/png")
|
.addHeader("Accept", "image/png")
|
||||||
.post(Json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
|
.post(body)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -57,18 +76,17 @@ object SystemDiagram {
|
|||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetches the diagram, flattens it onto a white background, and opens the Android share sheet. */
|
/** Always returns a diagram — API result when reachable, local block diagram otherwise. */
|
||||||
|
suspend fun fetchOrFallback(context: Context, state: DetailState, unit: UnitSystem): Bitmap =
|
||||||
|
fetch(state, unit) ?: drawFallback(context, state)
|
||||||
|
|
||||||
suspend fun exportAndShare(
|
suspend fun exportAndShare(
|
||||||
context: Context,
|
context: Context,
|
||||||
state: DetailState,
|
state: DetailState,
|
||||||
unit: UnitSystem,
|
unit: UnitSystem,
|
||||||
onError: () -> Unit,
|
onError: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val bitmap = fetch(state, unit)
|
val bitmap = fetchOrFallback(context, state, unit)
|
||||||
if (bitmap == null) {
|
|
||||||
withContext(Dispatchers.Main) { onError() }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val file = withContext(Dispatchers.IO) {
|
val file = withContext(Dispatchers.IO) {
|
||||||
val opaque = flattenOnWhite(bitmap)
|
val opaque = flattenOnWhite(bitmap)
|
||||||
val name = state.system?.name?.takeIf { it.isNotBlank() } ?: "System"
|
val name = state.system?.name?.takeIf { it.isNotBlank() } ?: "System"
|
||||||
@@ -83,6 +101,237 @@ object SystemDiagram {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Offline fallback: 3-column block diagram (mirrors iOS drawSystemDiagram)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun drawFallback(context: Context, state: DetailState): Bitmap {
|
||||||
|
val bmp = Bitmap.createBitmap(FB_W, FB_H, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bmp)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
|
||||||
|
val colW = (FB_W - FB_MARGIN * 2) / 3f
|
||||||
|
val chargerX = FB_MARGIN
|
||||||
|
val batteryX = FB_MARGIN + colW
|
||||||
|
val loadX = FB_MARGIN + colW * 2f
|
||||||
|
|
||||||
|
// Card background
|
||||||
|
val cardPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = FB_CARD_BG
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
canvas.drawRoundRect(
|
||||||
|
RectF(FB_MARGIN, FB_MARGIN, FB_W - FB_MARGIN, FB_H - FB_MARGIN),
|
||||||
|
24f, 24f, cardPaint,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Column headers
|
||||||
|
val headerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.GRAY
|
||||||
|
textSize = 32f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
val headerY = FB_MARGIN + 44f
|
||||||
|
canvas.drawText(
|
||||||
|
context.getString(R.string.overview_pdf_chargers_section).uppercase(Locale.getDefault()),
|
||||||
|
chargerX + colW / 2f, headerY, headerPaint,
|
||||||
|
)
|
||||||
|
canvas.drawText(
|
||||||
|
context.getString(R.string.battery_bank_header_title).uppercase(Locale.getDefault()),
|
||||||
|
batteryX + colW / 2f, headerY, headerPaint,
|
||||||
|
)
|
||||||
|
canvas.drawText(
|
||||||
|
context.getString(R.string.overview_pdf_loads_section).uppercase(Locale.getDefault()),
|
||||||
|
loadX + colW / 2f, headerY, headerPaint,
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentTop = headerY + 14f
|
||||||
|
|
||||||
|
// Charger blocks
|
||||||
|
val chargersToShow = state.chargers.take(FB_MAX_BLOCKS)
|
||||||
|
val chargerCentersY = mutableListOf<Float>()
|
||||||
|
chargersToShow.forEachIndexed { i, charger ->
|
||||||
|
val y = contentTop + i * (FB_BLOCK_H + FB_BLOCK_SPACING)
|
||||||
|
val rect = RectF(chargerX + FB_BLOCK_INSET, y, chargerX + colW - FB_BLOCK_INSET, y + FB_BLOCK_H)
|
||||||
|
drawComponentBlock(canvas, charger.name, "${Fmt.number(charger.effectivePowerWatts)} W", FB_CHARGER_COLOR, rect)
|
||||||
|
chargerCentersY.add(rect.centerY())
|
||||||
|
}
|
||||||
|
if (state.chargers.isEmpty()) drawDash(canvas, chargerX + colW / 2f, contentTop + 34f)
|
||||||
|
else if (state.chargers.size > FB_MAX_BLOCKS) drawMore(canvas, "+${state.chargers.size - FB_MAX_BLOCKS}", chargerX + colW / 2f, contentTop + FB_MAX_BLOCKS * (FB_BLOCK_H + FB_BLOCK_SPACING) + 26f)
|
||||||
|
|
||||||
|
// Battery block (height matches the taller outer column, minimum 260px)
|
||||||
|
val outerRows = maxOf(chargersToShow.size, minOf(state.loads.size, FB_MAX_BLOCKS)).coerceAtLeast(1)
|
||||||
|
val battH = (outerRows * (FB_BLOCK_H + FB_BLOCK_SPACING) - FB_BLOCK_SPACING).coerceAtLeast(FB_BATT_MIN_H)
|
||||||
|
val battRect = RectF(
|
||||||
|
batteryX + FB_BLOCK_INSET, contentTop,
|
||||||
|
batteryX + colW - FB_BLOCK_INSET, contentTop + battH,
|
||||||
|
)
|
||||||
|
drawBatteryBlock(context, canvas, state, battRect)
|
||||||
|
val batteryCenterY = battRect.centerY()
|
||||||
|
|
||||||
|
// Load blocks
|
||||||
|
val loadsToShow = state.loads.take(FB_MAX_BLOCKS)
|
||||||
|
val loadCentersY = mutableListOf<Float>()
|
||||||
|
loadsToShow.forEachIndexed { i, load ->
|
||||||
|
val y = contentTop + i * (FB_BLOCK_H + FB_BLOCK_SPACING)
|
||||||
|
val rect = RectF(loadX + FB_BLOCK_INSET, y, loadX + colW - FB_BLOCK_INSET, y + FB_BLOCK_H)
|
||||||
|
drawComponentBlock(canvas, load.name, "${Fmt.number(load.power)} W", FB_LOAD_COLOR, rect)
|
||||||
|
loadCentersY.add(rect.centerY())
|
||||||
|
}
|
||||||
|
if (state.loads.isEmpty()) drawDash(canvas, loadX + colW / 2f, contentTop + 34f)
|
||||||
|
else if (state.loads.size > FB_MAX_BLOCKS) drawMore(canvas, "+${state.loads.size - FB_MAX_BLOCKS}", loadX + colW / 2f, contentTop + FB_MAX_BLOCKS * (FB_BLOCK_H + FB_BLOCK_SPACING) + 26f)
|
||||||
|
|
||||||
|
// Arrows: chargers → battery, battery → loads
|
||||||
|
val chargerRight = chargerX + colW - FB_BLOCK_INSET
|
||||||
|
val battLeft = battRect.left
|
||||||
|
val battRight = battRect.right
|
||||||
|
val loadLeft = loadX + FB_BLOCK_INSET
|
||||||
|
for (cy in chargerCentersY) drawArrow(canvas, chargerRight, cy, battLeft, batteryCenterY, FB_CHARGER_COLOR)
|
||||||
|
for (cy in loadCentersY) drawArrow(canvas, battRight, batteryCenterY, loadLeft, cy, FB_LOAD_COLOR)
|
||||||
|
|
||||||
|
return bmp
|
||||||
|
}
|
||||||
|
|
||||||
|
/** White card with colored left accent bar, name (bold), and a detail line. */
|
||||||
|
private fun drawComponentBlock(canvas: Canvas, name: String, detail: String, color: Int, rect: RectF) {
|
||||||
|
val p = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
p.color = Color.WHITE
|
||||||
|
p.style = Paint.Style.FILL
|
||||||
|
canvas.drawRoundRect(rect, 10f, 10f, p)
|
||||||
|
|
||||||
|
p.color = Color.argb(55, 0, 0, 0)
|
||||||
|
p.style = Paint.Style.STROKE
|
||||||
|
p.strokeWidth = 1.5f
|
||||||
|
canvas.drawRoundRect(rect, 10f, 10f, p)
|
||||||
|
|
||||||
|
// Left accent bar (rounded on left, square on right so it sits flush)
|
||||||
|
p.color = color
|
||||||
|
p.style = Paint.Style.FILL
|
||||||
|
canvas.drawRoundRect(RectF(rect.left, rect.top, rect.left + 7f, rect.bottom), 10f, 10f, p)
|
||||||
|
canvas.drawRect(RectF(rect.left + 4f, rect.top, rect.left + 7f, rect.bottom), p)
|
||||||
|
|
||||||
|
val textLeft = rect.left + 16f
|
||||||
|
|
||||||
|
p.color = Color.BLACK
|
||||||
|
p.textSize = 28f
|
||||||
|
p.typeface = Typeface.DEFAULT_BOLD
|
||||||
|
p.textAlign = Paint.Align.LEFT
|
||||||
|
p.style = Paint.Style.FILL
|
||||||
|
val maxW = rect.width() - 20f
|
||||||
|
val nameChars = p.breakText(name, true, maxW - p.measureText("…"), null)
|
||||||
|
val nameStr = if (nameChars < name.length) name.take(nameChars) + "…" else name
|
||||||
|
canvas.drawText(nameStr, textLeft, rect.top + 36f, p)
|
||||||
|
|
||||||
|
p.color = Color.DKGRAY
|
||||||
|
p.textSize = 22f
|
||||||
|
p.typeface = Typeface.DEFAULT
|
||||||
|
canvas.drawText(detail, textLeft, rect.top + 66f, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green-tinted block showing battery bank totals (capacity, energy, usable). */
|
||||||
|
private fun drawBatteryBlock(context: Context, canvas: Canvas, state: DetailState, rect: RectF) {
|
||||||
|
val p = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
val r = Color.red(FB_BATTERY_COLOR); val g = Color.green(FB_BATTERY_COLOR); val b = Color.blue(FB_BATTERY_COLOR)
|
||||||
|
|
||||||
|
p.color = Color.argb(20, r, g, b)
|
||||||
|
p.style = Paint.Style.FILL
|
||||||
|
canvas.drawRoundRect(rect, 14f, 14f, p)
|
||||||
|
|
||||||
|
p.color = Color.argb(100, r, g, b)
|
||||||
|
p.style = Paint.Style.STROKE
|
||||||
|
p.strokeWidth = 2.5f
|
||||||
|
canvas.drawRoundRect(rect, 14f, 14f, p)
|
||||||
|
|
||||||
|
p.style = Paint.Style.FILL
|
||||||
|
p.textAlign = Paint.Align.CENTER
|
||||||
|
val cx = rect.centerX()
|
||||||
|
|
||||||
|
if (state.batteries.isEmpty()) {
|
||||||
|
p.color = Color.GRAY
|
||||||
|
p.textSize = 24f
|
||||||
|
p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)
|
||||||
|
canvas.drawText(context.getString(R.string.diagram_battery_no_batteries), cx, rect.centerY() + 10f, p)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val m = state.metrics
|
||||||
|
|
||||||
|
p.color = FB_BATTERY_COLOR
|
||||||
|
p.textSize = 48f
|
||||||
|
p.typeface = Typeface.DEFAULT
|
||||||
|
canvas.drawText("⚡", cx, rect.top + 60f, p)
|
||||||
|
|
||||||
|
p.color = Color.BLACK
|
||||||
|
p.textSize = 42f
|
||||||
|
p.typeface = Typeface.DEFAULT_BOLD
|
||||||
|
canvas.drawText("${Fmt.number(m.totalCapacity)} Ah", cx, rect.top + 112f, p)
|
||||||
|
|
||||||
|
p.color = Color.DKGRAY
|
||||||
|
p.textSize = 32f
|
||||||
|
p.typeface = Typeface.DEFAULT
|
||||||
|
canvas.drawText("${Fmt.number(m.totalEnergy)} Wh", cx, rect.top + 152f, p)
|
||||||
|
|
||||||
|
p.color = Color.GRAY
|
||||||
|
p.textSize = 26f
|
||||||
|
canvas.drawText("${context.getString(R.string.diagram_battery_usable)}: ${Fmt.number(m.totalUsableCapacity)} Ah", cx, rect.top + 186f, p)
|
||||||
|
|
||||||
|
if (state.batteries.size > 1) {
|
||||||
|
p.color = FB_BATTERY_COLOR
|
||||||
|
p.textSize = 26f
|
||||||
|
canvas.drawText("${state.batteries.size}×", cx, rect.bottom - 22f, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cubic bezier arrow from (x1, y1) to (x2, y2) with a filled triangular head. */
|
||||||
|
private fun drawArrow(canvas: Canvas, x1: Float, y1: Float, x2: Float, y2: Float, color: Int) {
|
||||||
|
val cr = Color.red(color); val cg = Color.green(color); val cb = Color.blue(color)
|
||||||
|
val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
this.color = Color.argb(128, cr, cg, cb)
|
||||||
|
style = Paint.Style.STROKE
|
||||||
|
strokeWidth = 2.5f
|
||||||
|
strokeCap = Paint.Cap.ROUND
|
||||||
|
}
|
||||||
|
|
||||||
|
val ctrlX = (x1 + x2) / 2f
|
||||||
|
val curvePath = Path().apply {
|
||||||
|
moveTo(x1, y1)
|
||||||
|
cubicTo(ctrlX, y1, ctrlX, y2, x2, y2)
|
||||||
|
}
|
||||||
|
canvas.drawPath(curvePath, linePaint)
|
||||||
|
|
||||||
|
val angle = atan2((y2 - y1).toDouble(), (x2 - x1).toDouble())
|
||||||
|
val s = 10.0
|
||||||
|
val headPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
this.color = Color.argb(128, cr, cg, cb)
|
||||||
|
style = Paint.Style.FILL
|
||||||
|
}
|
||||||
|
val headPath = Path().apply {
|
||||||
|
moveTo(x2, y2)
|
||||||
|
lineTo((x2 - s * cos(angle - Math.PI / 6)).toFloat(), (y2 - s * sin(angle - Math.PI / 6)).toFloat())
|
||||||
|
lineTo((x2 - s * cos(angle + Math.PI / 6)).toFloat(), (y2 - s * sin(angle + Math.PI / 6)).toFloat())
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
canvas.drawPath(headPath, headPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawDash(canvas: Canvas, cx: Float, y: Float) {
|
||||||
|
val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.GRAY; textSize = 28f; textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
canvas.drawText("–", cx, y, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawMore(canvas: Canvas, text: String, cx: Float, y: Float) {
|
||||||
|
val p = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.GRAY; textSize = 26f; textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
canvas.drawText(text, cx, y, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
private fun flattenOnWhite(source: Bitmap): Bitmap {
|
private fun flattenOnWhite(source: Bitmap): Bitmap {
|
||||||
val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
|
val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
|
||||||
Canvas(result).apply {
|
Canvas(result).apply {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import app.voltplan.cable.data.model.chemistry
|
|||||||
import app.voltplan.cable.data.model.effectivePowerWatts
|
import app.voltplan.cable.data.model.effectivePowerWatts
|
||||||
import app.voltplan.cable.data.model.energyWattHours
|
import app.voltplan.cable.data.model.energyWattHours
|
||||||
import app.voltplan.cable.data.model.usableCapacityAmpHours
|
import app.voltplan.cable.data.model.usableCapacityAmpHours
|
||||||
|
import app.voltplan.cable.data.model.usableEnergyWattHours
|
||||||
import app.voltplan.cable.ui.system.DetailState
|
import app.voltplan.cable.ui.system.DetailState
|
||||||
import app.voltplan.cable.util.Fmt
|
import app.voltplan.cable.util.Fmt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -27,8 +28,8 @@ private val ACCENT = Color.rgb(115, 87, 219)
|
|||||||
/** Renders a full system overview PDF and opens the Android share sheet. */
|
/** Renders a full system overview PDF and opens the Android share sheet. */
|
||||||
object SystemOverviewPdf {
|
object SystemOverviewPdf {
|
||||||
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
|
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
|
||||||
// Fetch the wiring diagram first (falls back to no diagram page if unavailable).
|
// Fetch the wiring diagram; falls back to a local block diagram if the API is unreachable.
|
||||||
val diagram = SystemDiagram.fetch(state, unit)
|
val diagram = SystemDiagram.fetchOrFallback(context, state, unit)
|
||||||
val file = withContext(Dispatchers.IO) {
|
val file = withContext(Dispatchers.IO) {
|
||||||
val doc = PdfDocument()
|
val doc = PdfDocument()
|
||||||
val w = PdfWriter(doc)
|
val w = PdfWriter(doc)
|
||||||
@@ -45,11 +46,11 @@ object SystemOverviewPdf {
|
|||||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargetime), m.estimatedChargeHours?.let { formatDurationHours(it) } ?: "—")
|
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargetime), m.estimatedChargeHours?.let { formatDurationHours(it) } ?: "—")
|
||||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalpower), "${Fmt.number(m.totalPower)} W")
|
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalpower), "${Fmt.number(m.totalPower)} W")
|
||||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalcurrent), "${Fmt.number(m.totalCurrent)} A")
|
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalcurrent), "${Fmt.number(m.totalCurrent)} A")
|
||||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalCapacity)} Ah")
|
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalUsableEnergy)} Wh")
|
||||||
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
|
summaryLine(w, context.getString(R.string.overview_pdf_summary_chargerpower), "${Fmt.number(m.totalChargerPower)} W")
|
||||||
|
|
||||||
// Full-page wiring diagram, followed by a fresh page for the entity tables.
|
// Full-page wiring diagram (always present — API result or local fallback).
|
||||||
diagram?.let { drawDiagramPage(w, it); w.beginPage() }
|
drawDiagramPage(w, diagram); w.beginPage()
|
||||||
|
|
||||||
if (state.loads.isNotEmpty()) {
|
if (state.loads.isNotEmpty()) {
|
||||||
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
|
w.gap(12f); w.text(context.getString(R.string.overview_pdf_loads_section), 18f, ACCENT, bold = true); w.divider()
|
||||||
@@ -58,9 +59,11 @@ object SystemOverviewPdf {
|
|||||||
val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit)
|
val cs = ElectricalCalculations.recommendedCrossSection(load.length, load.current, load.voltage, unit)
|
||||||
val gauge = if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", cs) else "${ElectricalCalculations.formatAWG(cs)} AWG"
|
val gauge = if (unit == UnitSystem.METRIC) String.format(Locale.US, "%.1f mm²", cs) else "${ElectricalCalculations.formatAWG(cs)} AWG"
|
||||||
val vdrop = ElectricalCalculations.voltageDropPercentage(load.length, load.current, load.voltage, unit)
|
val vdrop = ElectricalCalculations.voltageDropPercentage(load.length, load.current, load.voltage, unit)
|
||||||
|
val ploss = ElectricalCalculations.powerLoss(load.length, load.current, load.voltage, unit)
|
||||||
summaryLine(w, "${context.getString(R.string.overview_pdf_load_voltage)} / ${context.getString(R.string.overview_pdf_load_current)}", String.format(Locale.US, "%.1f V / %.1f A", load.voltage, load.current))
|
summaryLine(w, "${context.getString(R.string.overview_pdf_load_voltage)} / ${context.getString(R.string.overview_pdf_load_current)}", String.format(Locale.US, "%.1f V / %.1f A", load.voltage, load.current))
|
||||||
summaryLine(w, "${context.getString(R.string.overview_pdf_load_power)} / ${context.getString(R.string.overview_pdf_load_cable)}", "${Fmt.number(load.power)} W / $gauge")
|
summaryLine(w, "${context.getString(R.string.overview_pdf_load_power)} / ${context.getString(R.string.overview_pdf_load_length)}", "${Fmt.number(load.power)} W / ${Fmt.number(load.length)} ${unit.lengthUnit}")
|
||||||
summaryLine(w, "${context.getString(R.string.overview_pdf_load_vdrop)} / ${context.getString(R.string.overview_pdf_load_fuse)}", String.format(Locale.US, "%.1f%% / %.0f A", vdrop, ElectricalCalculations.recommendedFuse(load.current)))
|
summaryLine(w, "${context.getString(R.string.overview_pdf_load_cable)} / ${context.getString(R.string.overview_pdf_load_vdrop)}", String.format(Locale.US, "%s / %.1f%%", gauge, vdrop))
|
||||||
|
summaryLine(w, "${context.getString(R.string.overview_pdf_load_ploss)} / ${context.getString(R.string.overview_pdf_load_fuse)}", String.format(Locale.US, "%s W / %.0f A", Fmt.number(ploss), ElectricalCalculations.recommendedFuse(load.current)))
|
||||||
w.divider()
|
w.divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,7 @@ object SystemOverviewPdf {
|
|||||||
w.text(b.name, 14f, bold = true)
|
w.text(b.name, 14f, bold = true)
|
||||||
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_chemistry)} / ${context.getString(R.string.overview_pdf_battery_voltage)}", "${b.chemistry.displayName} / ${Fmt.number(b.nominalVoltage)} V")
|
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_chemistry)} / ${context.getString(R.string.overview_pdf_battery_voltage)}", "${b.chemistry.displayName} / ${Fmt.number(b.nominalVoltage)} V")
|
||||||
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_capacity)} / ${context.getString(R.string.overview_pdf_battery_usable)}", "${Fmt.number(b.capacityAmpHours)} Ah / ${Fmt.number(b.usableCapacityAmpHours)} Ah")
|
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_capacity)} / ${context.getString(R.string.overview_pdf_battery_usable)}", "${Fmt.number(b.capacityAmpHours)} Ah / ${Fmt.number(b.usableCapacityAmpHours)} Ah")
|
||||||
summaryLine(w, context.getString(R.string.overview_pdf_battery_energy), "${Fmt.number(b.energyWattHours)} Wh")
|
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_energy)} / ${context.getString(R.string.overview_pdf_battery_usableenergy)}", "${Fmt.number(b.energyWattHours)} Wh / ${Fmt.number(b.usableEnergyWattHours)} Wh")
|
||||||
w.divider()
|
w.divider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.batteries
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.model.Chemistry
|
import app.voltplan.cable.data.model.Chemistry
|
||||||
import app.voltplan.cable.data.model.SavedBattery
|
import app.voltplan.cable.data.model.SavedBattery
|
||||||
@@ -14,7 +15,7 @@ import java.util.UUID
|
|||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
data class BatteryState(
|
data class BatteryState(
|
||||||
val name: String = "New Battery",
|
val name: String = "",
|
||||||
val chemistry: Chemistry = Chemistry.LIFEPO4,
|
val chemistry: Chemistry = Chemistry.LIFEPO4,
|
||||||
val nominalVoltage: Double = 12.8,
|
val nominalVoltage: Double = 12.8,
|
||||||
val capacityAmpHours: Double = 100.0,
|
val capacityAmpHours: Double = 100.0,
|
||||||
@@ -33,7 +34,7 @@ data class BatteryState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class BatteryEditorViewModel(
|
class BatteryEditorViewModel(
|
||||||
app: CableApplication,
|
private val app: CableApplication,
|
||||||
private val systemId: String,
|
private val systemId: String,
|
||||||
batteryId: String?,
|
batteryId: String?,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -41,14 +42,25 @@ class BatteryEditorViewModel(
|
|||||||
private var id: String = batteryId ?: UUID.randomUUID().toString()
|
private var id: String = batteryId ?: UUID.randomUUID().toString()
|
||||||
private val isNew = batteryId == null
|
private val isNew = batteryId == null
|
||||||
private var loggedCreate = false
|
private var loggedCreate = false
|
||||||
|
private var systemName: String = ""
|
||||||
|
|
||||||
|
// Fields the editor doesn't touch but must survive a save round-trip.
|
||||||
|
private var affiliateURLString: String? = null
|
||||||
|
private var affiliateCountryCode: String? = null
|
||||||
|
private var bomCompletedItemIDs: List<String> = emptyList()
|
||||||
|
|
||||||
private val _state = MutableStateFlow(BatteryState())
|
private val _state = MutableStateFlow(BatteryState())
|
||||||
val state: StateFlow<BatteryState> = _state.asStateFlow()
|
val state: StateFlow<BatteryState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val system = repo.getSystem(systemId)
|
||||||
|
systemName = system?.name ?: ""
|
||||||
if (batteryId != null) {
|
if (batteryId != null) {
|
||||||
repo.getBattery(batteryId)?.let { b ->
|
repo.getBattery(batteryId)?.let { b ->
|
||||||
|
affiliateURLString = b.affiliateURLString
|
||||||
|
affiliateCountryCode = b.affiliateCountryCode
|
||||||
|
bomCompletedItemIDs = b.bomCompletedItemIDs
|
||||||
_state.value = BatteryState(
|
_state.value = BatteryState(
|
||||||
name = b.name,
|
name = b.name,
|
||||||
chemistry = Chemistry.fromRaw(b.chemistryRawValue),
|
chemistry = Chemistry.fromRaw(b.chemistryRawValue),
|
||||||
@@ -64,14 +76,22 @@ class BatteryEditorViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val color = repo.getSystem(systemId)?.colorName ?: "blue"
|
val color = system?.colorName ?: "blue"
|
||||||
val name = repo.uniqueComponentName(systemId, "New Battery")
|
val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_battery_new))
|
||||||
_state.value = _state.value.copy(name = name, colorName = color)
|
_state.value = _state.value.copy(name = name, colorName = color)
|
||||||
}
|
}
|
||||||
Analytics.log("Battery Editor Opened", mapOf("source" to if (isNew) "create" else "edit"))
|
Analytics.log("Battery Editor Opened", mapOf("source" to if (isNew) "create" else "edit", "system" to systemName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
// Mirrors iOS, which logs "Battery Updated" when the editor for an existing battery closes.
|
||||||
|
if (!isNew) {
|
||||||
|
Analytics.log("Battery Updated", mapOf("name" to _state.value.name, "system" to systemName))
|
||||||
|
}
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
private fun update(transform: (BatteryState) -> BatteryState) {
|
private fun update(transform: (BatteryState) -> BatteryState) {
|
||||||
_state.value = transform(_state.value)
|
_state.value = transform(_state.value)
|
||||||
persist()
|
persist()
|
||||||
@@ -120,13 +140,16 @@ class BatteryEditorViewModel(
|
|||||||
iconName = s.iconName,
|
iconName = s.iconName,
|
||||||
colorName = s.colorName,
|
colorName = s.colorName,
|
||||||
systemId = systemId,
|
systemId = systemId,
|
||||||
|
affiliateURLString = affiliateURLString,
|
||||||
|
affiliateCountryCode = affiliateCountryCode,
|
||||||
|
bomCompletedItemIDs = bomCompletedItemIDs,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
)
|
)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repo.upsertBattery(battery)
|
repo.upsertBattery(battery)
|
||||||
if (isNew && !loggedCreate) {
|
if (isNew && !loggedCreate) {
|
||||||
loggedCreate = true
|
loggedCreate = true
|
||||||
Analytics.log("Battery Created", mapOf("name" to s.name, "voltage" to s.nominalVoltage, "capacity" to s.capacityAmpHours, "chemistry" to s.chemistry.rawValue))
|
Analytics.log("Battery Created", mapOf("name" to s.name, "system" to systemName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.chargers
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.LocaleDefaults
|
import app.voltplan.cable.data.LocaleDefaults
|
||||||
import app.voltplan.cable.data.model.PowerSourceType
|
import app.voltplan.cable.data.model.PowerSourceType
|
||||||
@@ -17,7 +18,7 @@ import java.util.UUID
|
|||||||
enum class PowerEntryMode { CURRENT, POWER }
|
enum class PowerEntryMode { CURRENT, POWER }
|
||||||
|
|
||||||
data class ChargerState(
|
data class ChargerState(
|
||||||
val name: String = "New Charger",
|
val name: String = "",
|
||||||
val sourceType: PowerSourceType = PowerSourceType.SHORE,
|
val sourceType: PowerSourceType = PowerSourceType.SHORE,
|
||||||
val inputVoltage: Double = 230.0,
|
val inputVoltage: Double = 230.0,
|
||||||
val outputVoltage: Double = 14.2,
|
val outputVoltage: Double = 14.2,
|
||||||
@@ -38,7 +39,7 @@ data class ChargerState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ChargerEditorViewModel(
|
class ChargerEditorViewModel(
|
||||||
app: CableApplication,
|
private val app: CableApplication,
|
||||||
private val systemId: String,
|
private val systemId: String,
|
||||||
chargerId: String?,
|
chargerId: String?,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -46,14 +47,27 @@ class ChargerEditorViewModel(
|
|||||||
private var id: String = chargerId ?: UUID.randomUUID().toString()
|
private var id: String = chargerId ?: UUID.randomUUID().toString()
|
||||||
private val isNew = chargerId == null
|
private val isNew = chargerId == null
|
||||||
private var loggedCreate = false
|
private var loggedCreate = false
|
||||||
|
private var systemName: String = ""
|
||||||
|
|
||||||
|
// Fields the editor doesn't touch but must survive a save round-trip.
|
||||||
|
private var remoteIconURLString: String? = null
|
||||||
|
private var affiliateURLString: String? = null
|
||||||
|
private var affiliateCountryCode: String? = null
|
||||||
|
private var bomCompletedItemIDs: List<String> = emptyList()
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ChargerState(inputVoltage = LocaleDefaults.mainsVoltage))
|
private val _state = MutableStateFlow(ChargerState(inputVoltage = LocaleDefaults.mainsVoltage))
|
||||||
val state: StateFlow<ChargerState> = _state.asStateFlow()
|
val state: StateFlow<ChargerState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val system = repo.getSystem(systemId)
|
||||||
|
systemName = system?.name ?: ""
|
||||||
if (chargerId != null) {
|
if (chargerId != null) {
|
||||||
repo.getCharger(chargerId)?.let { c ->
|
repo.getCharger(chargerId)?.let { c ->
|
||||||
|
remoteIconURLString = c.remoteIconURLString
|
||||||
|
affiliateURLString = c.affiliateURLString
|
||||||
|
affiliateCountryCode = c.affiliateCountryCode
|
||||||
|
bomCompletedItemIDs = c.bomCompletedItemIDs
|
||||||
val mode = if (c.maxPowerWatts > 0) PowerEntryMode.POWER else PowerEntryMode.CURRENT
|
val mode = if (c.maxPowerWatts > 0) PowerEntryMode.POWER else PowerEntryMode.CURRENT
|
||||||
val current = if (c.maxPowerWatts > 0 && c.outputVoltage > 0) Fmt.roundToTenth(c.maxPowerWatts / c.outputVoltage) else c.maxCurrentAmps
|
val current = if (c.maxPowerWatts > 0 && c.outputVoltage > 0) Fmt.roundToTenth(c.maxPowerWatts / c.outputVoltage) else c.maxCurrentAmps
|
||||||
_state.value = ChargerState(
|
_state.value = ChargerState(
|
||||||
@@ -70,14 +84,22 @@ class ChargerEditorViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val color = repo.getSystem(systemId)?.colorName ?: "orange"
|
val color = system?.colorName ?: "orange"
|
||||||
val name = repo.uniqueComponentName(systemId, "New Charger")
|
val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_charger_new))
|
||||||
_state.value = _state.value.copy(name = name, colorName = color)
|
_state.value = _state.value.copy(name = name, colorName = color)
|
||||||
}
|
}
|
||||||
Analytics.log("Charger Editor Opened", mapOf("source" to if (isNew) "create" else "edit"))
|
Analytics.log("Charger Editor Opened", mapOf("source" to if (isNew) "create" else "edit", "system" to systemName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
// Mirrors iOS, which logs "Charger Updated" when the editor for an existing charger closes.
|
||||||
|
if (!isNew) {
|
||||||
|
Analytics.log("Charger Updated", mapOf("name" to _state.value.name, "system" to systemName))
|
||||||
|
}
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
|
||||||
private fun update(transform: (ChargerState) -> ChargerState) {
|
private fun update(transform: (ChargerState) -> ChargerState) {
|
||||||
_state.value = transform(_state.value)
|
_state.value = transform(_state.value)
|
||||||
persist()
|
persist()
|
||||||
@@ -135,13 +157,17 @@ class ChargerEditorViewModel(
|
|||||||
colorName = s.colorName,
|
colorName = s.colorName,
|
||||||
systemId = systemId,
|
systemId = systemId,
|
||||||
timestamp = System.currentTimeMillis(),
|
timestamp = System.currentTimeMillis(),
|
||||||
|
remoteIconURLString = remoteIconURLString,
|
||||||
|
affiliateURLString = affiliateURLString,
|
||||||
|
affiliateCountryCode = affiliateCountryCode,
|
||||||
|
bomCompletedItemIDs = bomCompletedItemIDs,
|
||||||
powerSourceType = s.sourceType.rawValue,
|
powerSourceType = s.sourceType.rawValue,
|
||||||
)
|
)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repo.upsertCharger(charger)
|
repo.upsertCharger(charger)
|
||||||
if (isNew && !loggedCreate) {
|
if (isNew && !loggedCreate) {
|
||||||
loggedCreate = true
|
loggedCreate = true
|
||||||
Analytics.log("Charger Created", mapOf("name" to s.name, "output_voltage" to s.outputVoltage, "max_current" to s.maxCurrentAmps, "max_power" to s.maxPowerWatts))
|
Analytics.log("Charger Created", mapOf("name" to s.name, "system" to systemName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.ui.sfSymbol
|
import app.voltplan.cable.ui.sfSymbol
|
||||||
import app.voltplan.cable.ui.theme.componentColor
|
import app.voltplan.cable.ui.theme.componentColor
|
||||||
import app.voltplan.cable.ui.theme.curatedColorNames
|
import app.voltplan.cable.ui.theme.curatedColorNames
|
||||||
@@ -77,7 +79,7 @@ fun AppearanceEditorSheet(
|
|||||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
||||||
TextButton(onClick = { onSave(name, icon, color); onDismiss() }) {
|
TextButton(onClick = { onSave(name, icon, color); onDismiss() }) {
|
||||||
Text("Save", fontWeight = FontWeight.SemiBold)
|
Text(stringResource(R.string.action_save), fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +114,7 @@ fun AppearanceEditorSheet(
|
|||||||
|
|
||||||
extra?.invoke()
|
extra?.invoke()
|
||||||
|
|
||||||
Text("Icon", style = MaterialTheme.typography.titleSmall)
|
Text(stringResource(R.string.editor_icon), style = MaterialTheme.typography.titleSmall)
|
||||||
GridRows(items = icons, columns = 5) { symbol ->
|
GridRows(items = icons, columns = 5) { symbol ->
|
||||||
val selected = symbol == icon
|
val selected = symbol == icon
|
||||||
Box(
|
Box(
|
||||||
@@ -133,7 +135,7 @@ fun AppearanceEditorSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text("Color", style = MaterialTheme.typography.titleSmall)
|
Text(stringResource(R.string.editor_color), style = MaterialTheme.typography.titleSmall)
|
||||||
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
GridRows(items = curatedColorNames, columns = 6) { colorName ->
|
||||||
val c = componentColor(colorName)
|
val c = componentColor(colorName)
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.util.Fmt
|
import app.voltplan.cable.util.Fmt
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -113,8 +115,8 @@ fun ValueEditDialog(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { Fmt.parseInput(text)?.let(onConfirm); onDismiss() }) { Text("Save") }
|
TextButton(onClick = { Fmt.parseInput(text)?.let(onConfirm); onDismiss() }) { Text(stringResource(R.string.action_save)) }
|
||||||
},
|
},
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,13 +67,12 @@ fun ComponentLibraryScreen(
|
|||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
Analytics.log(
|
val props = mutableMapOf<String, Any>(
|
||||||
"Component Library Opened",
|
|
||||||
mapOf(
|
|
||||||
"source" to if (targetSystemId != null) "system" else "systems-list",
|
"source" to if (targetSystemId != null) "system" else "systems-list",
|
||||||
"type" to libraryType.typeValue,
|
"type" to libraryType.typeValue,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
vm.systemName(targetSystemId)?.let { props["system"] = it }
|
||||||
|
Analytics.log("Component Library Opened", props)
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.loads
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.calc.ElectricalCalculations
|
import app.voltplan.cable.calc.ElectricalCalculations
|
||||||
import app.voltplan.cable.data.UnitSystem
|
import app.voltplan.cable.data.UnitSystem
|
||||||
@@ -15,7 +16,7 @@ import kotlinx.coroutines.launch
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class CalcState(
|
data class CalcState(
|
||||||
val loadName: String = "My Load",
|
val loadName: String = "",
|
||||||
val voltage: Double = 12.0,
|
val voltage: Double = 12.0,
|
||||||
val current: Double = 5.0,
|
val current: Double = 5.0,
|
||||||
val power: Double = 60.0,
|
val power: Double = 60.0,
|
||||||
@@ -36,7 +37,7 @@ data class CalcState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class CalculatorViewModel(
|
class CalculatorViewModel(
|
||||||
app: CableApplication,
|
private val app: CableApplication,
|
||||||
private val systemId: String,
|
private val systemId: String,
|
||||||
loadId: String?,
|
loadId: String?,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -73,11 +74,15 @@ class CalculatorViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Mirror SystemComponentsPersistence default load values.
|
// Mirror SystemComponentsPersistence default load values, including the
|
||||||
_state.value = CalcState()
|
// localized unique default name ("New Load", "New Load 2", ...).
|
||||||
|
viewModelScope.launch {
|
||||||
|
val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_load_new))
|
||||||
|
_state.value = CalcState(loadName = name)
|
||||||
persist(created = true)
|
persist(created = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun mode() = if (_state.value.isWattMode) "watt" else "amp"
|
private fun mode() = if (_state.value.isWattMode) "watt" else "amp"
|
||||||
private suspend fun systemName() = repo.getSystem(systemId)?.name ?: ""
|
private suspend fun systemName() = repo.getSystem(systemId)?.name ?: ""
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ fun SystemDetailScreen(
|
|||||||
system.name,
|
system.name,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.padding(start = 8.dp).clickable { showSystemEditor = true },
|
modifier = Modifier.padding(start = 8.dp).clickable {
|
||||||
|
vm.logSystemEditorOpened("toolbar")
|
||||||
|
showSystemEditor = true
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,14 @@ class SystemDetailViewModel(
|
|||||||
Analytics.log("Tab Changed", mapOf("tab" to tab, "system" to (state.value.system?.name ?: "")))
|
Analytics.log("Tab Changed", mapOf("tab" to tab, "system" to (state.value.system?.name ?: "")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun logSystemEditorOpened(source: String) {
|
||||||
|
Analytics.log("System Editor Opened", mapOf("source" to source, "system" to (state.value.system?.name ?: "")))
|
||||||
|
}
|
||||||
|
|
||||||
fun saveSystem(name: String, location: String, iconName: String, colorName: String) {
|
fun saveSystem(name: String, location: String, iconName: String, colorName: String) {
|
||||||
val current = state.value.system ?: return
|
val current = state.value.system ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
repo.upsertSystem(current.copy(name = name, location = location, iconName = iconName, colorName = colorName))
|
repo.upsertSystem(current.copy(name = name, location = location, iconName = iconName, colorName = colorName))
|
||||||
Analytics.log("System Updated", mapOf("name" to name))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import androidx.compose.material.icons.outlined.Add
|
|||||||
import androidx.compose.material.icons.outlined.ChevronRight
|
import androidx.compose.material.icons.outlined.ChevronRight
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -28,6 +29,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -59,6 +61,28 @@ fun SystemsScreen(
|
|||||||
vm: SystemsViewModel = viewModel(),
|
vm: SystemsViewModel = viewModel(),
|
||||||
) {
|
) {
|
||||||
val summaries by vm.systems.collectAsStateWithLifecycle()
|
val summaries by vm.systems.collectAsStateWithLifecycle()
|
||||||
|
var systemPendingDeletion by remember { mutableStateOf<SystemSummary?>(null) }
|
||||||
|
|
||||||
|
systemPendingDeletion?.let { pending ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { systemPendingDeletion = null },
|
||||||
|
title = { Text(stringResource(R.string.delete_system_confirm_title)) },
|
||||||
|
text = { Text(stringResource(R.string.delete_system_confirm_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
vm.deleteSystem(pending)
|
||||||
|
systemPendingDeletion = null
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.action_delete))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { systemPendingDeletion = null }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -84,7 +108,7 @@ fun SystemsScreen(
|
|||||||
SystemsOnboarding(
|
SystemsOnboarding(
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
onCreate = { name ->
|
onCreate = { name ->
|
||||||
vm.createSystem(name, source = "onboarding", randomColor = true, onCreated = onOpenSystem)
|
vm.createSystem(name, source = "named", randomColor = true, onCreated = onOpenSystem)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -99,7 +123,7 @@ fun SystemsScreen(
|
|||||||
vm.logOpen(summary, "list")
|
vm.logOpen(summary, "list")
|
||||||
onOpenSystem(summary.system.id)
|
onOpenSystem(summary.system.id)
|
||||||
},
|
},
|
||||||
onDelete = { vm.deleteSystem(summary) },
|
onDelete = { systemPendingDeletion = summary },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +191,11 @@ private fun SystemsOnboarding(modifier: Modifier = Modifier, onCreate: (String)
|
|||||||
val defaultName = stringResource(R.string.default_system_name)
|
val defaultName = stringResource(R.string.default_system_name)
|
||||||
val effective = remember(name) { name }
|
val effective = remember(name) { name }
|
||||||
|
|
||||||
|
// Mirrors iOS, which logs "Launched" when the onboarding view appears.
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||||
|
app.voltplan.cable.analytics.Analytics.log("Launched")
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.Application
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.voltplan.cable.CableApplication
|
import app.voltplan.cable.CableApplication
|
||||||
|
import app.voltplan.cable.R
|
||||||
import app.voltplan.cable.analytics.Analytics
|
import app.voltplan.cable.analytics.Analytics
|
||||||
import app.voltplan.cable.data.model.ElectricalSystem
|
import app.voltplan.cable.data.model.ElectricalSystem
|
||||||
import app.voltplan.cable.data.model.SavedLoad
|
import app.voltplan.cable.data.model.SavedLoad
|
||||||
@@ -37,7 +38,8 @@ class SystemsViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
/** Creates a system and returns its id via the callback. [source] is for analytics. */
|
/** Creates a system and returns its id via the callback. [source] is for analytics. */
|
||||||
fun createSystem(preferredName: String?, source: String, randomColor: Boolean, onCreated: (String) -> Unit) {
|
fun createSystem(preferredName: String?, source: String, randomColor: Boolean, onCreated: (String) -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val base = preferredName?.trim().takeUnless { it.isNullOrEmpty() } ?: "New System"
|
val base = preferredName?.trim().takeUnless { it.isNullOrEmpty() }
|
||||||
|
?: getApplication<Application>().getString(R.string.default_system_new)
|
||||||
val name = repo.uniqueSystemName(base)
|
val name = repo.uniqueSystemName(base)
|
||||||
val color = if (randomColor) SystemIconMapper.colorOptions.random() else "blue"
|
val color = if (randomColor) SystemIconMapper.colorOptions.random() else "blue"
|
||||||
val system = ElectricalSystem(
|
val system = ElectricalSystem(
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
<!-- Batteries -->
|
<!-- Batteries -->
|
||||||
<string name="battery_bank_header_title">Batterien</string>
|
<string name="battery_bank_header_title">Batterien</string>
|
||||||
|
<string name="diagram_battery_no_batteries">Keine Batterien</string>
|
||||||
|
<string name="diagram_battery_usable">Nutzbar</string>
|
||||||
<string name="battery_metric_count">Batterien</string>
|
<string name="battery_metric_count">Batterien</string>
|
||||||
<string name="battery_metric_capacity">Kapazität</string>
|
<string name="battery_metric_capacity">Kapazität</string>
|
||||||
<string name="battery_metric_usable_capacity">Nutzbare Kapazität</string>
|
<string name="battery_metric_usable_capacity">Nutzbare Kapazität</string>
|
||||||
@@ -263,4 +265,18 @@
|
|||||||
|
|
||||||
<!-- Misc -->
|
<!-- Misc -->
|
||||||
<string name="component_fallback_name">Komponente</string>
|
<string name="component_fallback_name">Komponente</string>
|
||||||
|
|
||||||
|
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
|
||||||
|
<string name="action_cancel">Abbrechen</string>
|
||||||
|
<string name="editor_icon">Symbol</string>
|
||||||
|
<string name="editor_color">Farbe</string>
|
||||||
|
<string name="overview_pdf_load_length">Kabellänge</string>
|
||||||
|
<string name="overview_pdf_load_ploss">Leistungsverlust</string>
|
||||||
|
<string name="overview_pdf_battery_usableenergy">Nutzbare Energie</string>
|
||||||
|
<string name="default_load_new">Neuer Verbraucher</string>
|
||||||
|
<string name="default_battery_new">Neue Batterie</string>
|
||||||
|
<string name="default_charger_new">Neues Ladegerät</string>
|
||||||
|
<string name="default_system_new">Neues System</string>
|
||||||
|
<string name="delete_system_confirm_title">System löschen?</string>
|
||||||
|
<string name="delete_system_confirm_message">Das System und alle seine Komponenten werden dauerhaft gelöscht.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
<!-- Batteries -->
|
<!-- Batteries -->
|
||||||
<string name="battery_bank_header_title">Banco de baterías</string>
|
<string name="battery_bank_header_title">Banco de baterías</string>
|
||||||
|
<string name="diagram_battery_no_batteries">Sin baterías</string>
|
||||||
|
<string name="diagram_battery_usable">Utilizable</string>
|
||||||
<string name="battery_metric_count">Baterías</string>
|
<string name="battery_metric_count">Baterías</string>
|
||||||
<string name="battery_metric_capacity">Capacidad</string>
|
<string name="battery_metric_capacity">Capacidad</string>
|
||||||
<string name="battery_metric_usable_capacity">Capacidad utilizable</string>
|
<string name="battery_metric_usable_capacity">Capacidad utilizable</string>
|
||||||
@@ -263,4 +265,18 @@
|
|||||||
|
|
||||||
<!-- Misc -->
|
<!-- Misc -->
|
||||||
<string name="component_fallback_name">Componente</string>
|
<string name="component_fallback_name">Componente</string>
|
||||||
|
|
||||||
|
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
|
||||||
|
<string name="action_cancel">Cancelar</string>
|
||||||
|
<string name="editor_icon">Icono</string>
|
||||||
|
<string name="editor_color">Color</string>
|
||||||
|
<string name="overview_pdf_load_length">Longitud del cable</string>
|
||||||
|
<string name="overview_pdf_load_ploss">Pérdida de potencia</string>
|
||||||
|
<string name="overview_pdf_battery_usableenergy">Energía utilizable</string>
|
||||||
|
<string name="default_load_new">Carga nueva</string>
|
||||||
|
<string name="default_battery_new">Nueva batería</string>
|
||||||
|
<string name="default_charger_new">Nuevo cargador</string>
|
||||||
|
<string name="default_system_new">Sistema nuevo</string>
|
||||||
|
<string name="delete_system_confirm_title">¿Eliminar sistema?</string>
|
||||||
|
<string name="delete_system_confirm_message">Esto eliminará permanentemente el sistema y todos sus componentes.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
<!-- Batteries -->
|
<!-- Batteries -->
|
||||||
<string name="battery_bank_header_title">Banque de batteries</string>
|
<string name="battery_bank_header_title">Banque de batteries</string>
|
||||||
|
<string name="diagram_battery_no_batteries">Pas de batteries</string>
|
||||||
|
<string name="diagram_battery_usable">Utilisable</string>
|
||||||
<string name="battery_metric_count">Batteries</string>
|
<string name="battery_metric_count">Batteries</string>
|
||||||
<string name="battery_metric_capacity">Capacité</string>
|
<string name="battery_metric_capacity">Capacité</string>
|
||||||
<string name="battery_metric_usable_capacity">Capacité utilisable</string>
|
<string name="battery_metric_usable_capacity">Capacité utilisable</string>
|
||||||
@@ -263,4 +265,18 @@
|
|||||||
|
|
||||||
<!-- Misc -->
|
<!-- Misc -->
|
||||||
<string name="component_fallback_name">Composant</string>
|
<string name="component_fallback_name">Composant</string>
|
||||||
|
|
||||||
|
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
|
||||||
|
<string name="action_cancel">Annuler</string>
|
||||||
|
<string name="editor_icon">Icône</string>
|
||||||
|
<string name="editor_color">Couleur</string>
|
||||||
|
<string name="overview_pdf_load_length">Longueur du câble</string>
|
||||||
|
<string name="overview_pdf_load_ploss">Perte de puissance</string>
|
||||||
|
<string name="overview_pdf_battery_usableenergy">Énergie utilisable</string>
|
||||||
|
<string name="default_load_new">Nouvelle charge</string>
|
||||||
|
<string name="default_battery_new">Nouvelle batterie</string>
|
||||||
|
<string name="default_charger_new">Nouveau chargeur</string>
|
||||||
|
<string name="default_system_new">Nouveau système</string>
|
||||||
|
<string name="delete_system_confirm_title">Supprimer le système ?</string>
|
||||||
|
<string name="delete_system_confirm_message">Cela supprimera définitivement le système et tous ses composants.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
<!-- Batteries -->
|
<!-- Batteries -->
|
||||||
<string name="battery_bank_header_title">Accubank</string>
|
<string name="battery_bank_header_title">Accubank</string>
|
||||||
|
<string name="diagram_battery_no_batteries">Geen batterijen</string>
|
||||||
|
<string name="diagram_battery_usable">Bruikbaar</string>
|
||||||
<string name="battery_metric_count">Batterijen</string>
|
<string name="battery_metric_count">Batterijen</string>
|
||||||
<string name="battery_metric_capacity">Capaciteit</string>
|
<string name="battery_metric_capacity">Capaciteit</string>
|
||||||
<string name="battery_metric_usable_capacity">Beschikbare capaciteit</string>
|
<string name="battery_metric_usable_capacity">Beschikbare capaciteit</string>
|
||||||
@@ -263,4 +265,18 @@
|
|||||||
|
|
||||||
<!-- Misc -->
|
<!-- Misc -->
|
||||||
<string name="component_fallback_name">Component</string>
|
<string name="component_fallback_name">Component</string>
|
||||||
|
|
||||||
|
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
|
||||||
|
<string name="action_cancel">Annuleren</string>
|
||||||
|
<string name="editor_icon">Pictogram</string>
|
||||||
|
<string name="editor_color">Kleur</string>
|
||||||
|
<string name="overview_pdf_load_length">Kabellengte</string>
|
||||||
|
<string name="overview_pdf_load_ploss">Vermogensverlies</string>
|
||||||
|
<string name="overview_pdf_battery_usableenergy">Bruikbare energie</string>
|
||||||
|
<string name="default_load_new">Nieuwe last</string>
|
||||||
|
<string name="default_battery_new">Nieuwe batterij</string>
|
||||||
|
<string name="default_charger_new">Nieuwe lader</string>
|
||||||
|
<string name="default_system_new">Nieuw systeem</string>
|
||||||
|
<string name="delete_system_confirm_title">Systeem verwijderen?</string>
|
||||||
|
<string name="delete_system_confirm_message">Dit verwijdert het systeem en alle bijbehorende componenten permanent.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
<!-- Batteries -->
|
<!-- Batteries -->
|
||||||
<string name="battery_bank_header_title">Battery Bank</string>
|
<string name="battery_bank_header_title">Battery Bank</string>
|
||||||
|
<string name="diagram_battery_no_batteries">No batteries</string>
|
||||||
|
<string name="diagram_battery_usable">Usable</string>
|
||||||
<string name="battery_metric_count">Batteries</string>
|
<string name="battery_metric_count">Batteries</string>
|
||||||
<string name="battery_metric_capacity">Capacity</string>
|
<string name="battery_metric_capacity">Capacity</string>
|
||||||
<string name="battery_metric_usable_capacity">Usable Capacity</string>
|
<string name="battery_metric_usable_capacity">Usable Capacity</string>
|
||||||
@@ -263,4 +265,18 @@
|
|||||||
|
|
||||||
<!-- Misc -->
|
<!-- Misc -->
|
||||||
<string name="component_fallback_name">Component</string>
|
<string name="component_fallback_name">Component</string>
|
||||||
|
|
||||||
|
<!-- Cross-platform parity additions (mirroring iOS Localizable.strings) -->
|
||||||
|
<string name="action_cancel">Cancel</string>
|
||||||
|
<string name="editor_icon">Icon</string>
|
||||||
|
<string name="editor_color">Color</string>
|
||||||
|
<string name="overview_pdf_load_length">Cable Length</string>
|
||||||
|
<string name="overview_pdf_load_ploss">Power Loss</string>
|
||||||
|
<string name="overview_pdf_battery_usableenergy">Usable Energy</string>
|
||||||
|
<string name="default_load_new">New Load</string>
|
||||||
|
<string name="default_battery_new">New Battery</string>
|
||||||
|
<string name="default_charger_new">New Charger</string>
|
||||||
|
<string name="default_system_new">New System</string>
|
||||||
|
<string name="delete_system_confirm_title">Delete System?</string>
|
||||||
|
<string name="delete_system_confirm_message">This will permanently delete the system and all its components.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user