Compare commits

..

3 Commits

Author SHA1 Message Date
022e309873 Show rating prompt after share sheet is dismissed
iOS: moved registerSuccessfulExport() into onDismiss so it fires
after the share sheet closes, not while it is still open.
Android: hold launchReview() until the activity is RESUMED so the
dialog cannot overlap the share chooser.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:55:09 +02:00
01cdaf1861 Require confirmation before deleting a system (iOS + Android)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:46:32 +02:00
d4b490ea07 Align Android with iOS reference behavior
- Overview PDF: usable energy summary, cable length, power loss,
  usable energy rows (matching iOS tables)
- Analytics: align event names and properties with iOS
- Localize default component names and editor dialog labels
- Unit system default via device measurement system (API 28+)
- Preserve BOM/affiliate fields when editing batteries/chargers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:36:18 +02:00
31 changed files with 574 additions and 71 deletions

View File

@@ -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)";

View File

@@ -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.")

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)";

View File

@@ -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)";

View File

@@ -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)";

View File

@@ -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)";

View File

@@ -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\"")

View File

@@ -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

View File

@@ -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)
} }
} }

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -16,6 +16,7 @@ import app.voltplan.cable.data.model.chemistry
import app.voltplan.cable.data.model.effectivePowerWatts import app.voltplan.cable.data.model.effectivePowerWatts
import app.voltplan.cable.data.model.energyWattHours import app.voltplan.cable.data.model.energyWattHours
import app.voltplan.cable.data.model.usableCapacityAmpHours import app.voltplan.cable.data.model.usableCapacityAmpHours
import app.voltplan.cable.data.model.usableEnergyWattHours
import app.voltplan.cable.ui.system.DetailState import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.util.Fmt import app.voltplan.cable.util.Fmt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -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()
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>