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.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
"overview.share.diagram" = "Wiring Diagram";
"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)
}
.alert(
@@ -1133,7 +1136,6 @@ struct LoadsView: View {
await MainActor.run {
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
isExportingOverview = false
ReviewPrompt.registerSuccessfulExport()
}
} catch {
await MainActor.run {
@@ -1163,7 +1165,6 @@ struct LoadsView: View {
"system": snapshot.systemName,
])
overviewShareItem = OverviewShareItem(shareItems: [url], tempURL: url)
ReviewPrompt.registerSuccessfulExport()
} else {
overviewExportError = OverviewExportError(
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")
.sheet(item: $activeShareItem, onDismiss: cleanupShareItem) { item in
.sheet(item: $activeShareItem, onDismiss: {
cleanupShareItem()
ReviewPrompt.registerSuccessfulExport()
}) { item in
ShareSheet(items: [item.url])
}
.alert(item: $exportError) { error in
@@ -346,7 +349,6 @@ struct SystemBillOfMaterialsView: View {
)
await MainActor.run {
activeShareItem = ExportedPDFShareItem(url: url)
ReviewPrompt.registerSuccessfulExport()
}
} catch {
await MainActor.run {

View File

@@ -18,6 +18,7 @@ struct SystemsView: View {
@State private var showingComponentLibrary = false
@State private var showingSettings = false
@State private var hasPerformedInitialAutoNavigation = false
@State private var systemsPendingDeletion: [ElectricalSystem]?
private let systemColorOptions = [
"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"))
.accessibilityAddTraits(.isButton)
}
.onDelete(perform: deleteSystems)
.onDelete { offsets in
systemsPendingDeletion = offsets.map { systems[$0] }
}
}
.accessibilityIdentifier("systems-list")
}
@@ -133,6 +136,28 @@ struct SystemsView: View {
SettingsView()
.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 {
@@ -416,8 +441,7 @@ struct SystemsView: View {
return candidate
}
private func deleteSystems(offsets: IndexSet) {
let systemsToDelete = offsets.map { systems[$0] }
private func deleteSystems(_ systemsToDelete: [ElectricalSystem]) {
withAnimation {
for system in systemsToDelete {
AnalyticsTracker.log(

View File

@@ -444,6 +444,11 @@
"charger.source.generator" = "Generator";
"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
"overview.share.diagram" = "Schaltplan";
"overview.share.pdf" = "Vollständiger Bericht (PDF)";

View File

@@ -406,6 +406,11 @@
"charger.source.generator" = "Generador";
"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
"overview.share.diagram" = "Diagrama de cableado";
"overview.share.pdf" = "Informe completo (PDF)";

View File

@@ -406,6 +406,11 @@
"charger.source.generator" = "Groupe électrogène";
"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
"overview.share.diagram" = "Schéma de câblage";
"overview.share.pdf" = "Rapport complet (PDF)";

View File

@@ -406,6 +406,11 @@
"charger.source.generator" = "Generator";
"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
"overview.share.diagram" = "Bedradingsschema";
"overview.share.pdf" = "Volledig rapport (PDF)";

View File

@@ -25,8 +25,8 @@ android {
applicationId = "app.voltplan.cable"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 85
versionName = "1.7.0"
// Aptabase analytics — mirrors the iOS configuration (the iPhone app's tracker).
buildConfigField("String", "APTABASE_APP_KEY", "\"A-SH-4260269603\"")

View File

@@ -10,3 +10,5 @@
# Retrofit
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-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.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import app.voltplan.cable.BuildConfig
import app.voltplan.cable.analytics.Analytics
import com.google.android.play.core.ktx.launchReview
import com.google.android.play.core.ktx.requestReview
import com.google.android.play.core.review.ReviewManagerFactory
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
/**
@@ -113,6 +116,11 @@ object ReviewPrompt {
runCatching {
val manager = ReviewManagerFactory.create(context)
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)
}
}

View File

@@ -1,5 +1,8 @@
package app.voltplan.cable.data
import android.icu.util.LocaleData
import android.icu.util.ULocale
import android.os.Build
import java.util.Locale
/** Metric (mm², m) or imperial (AWG, ft). Mirrors the iOS `UnitSystem` enum. */
@@ -13,10 +16,16 @@ enum class UnitSystem(val rawValue: String) {
companion object {
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 {
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()
// 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
}
}

View File

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

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.library
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.Chemistry
import app.voltplan.cable.data.model.ElectricalSystem
@@ -55,10 +56,14 @@ class ComponentLibraryViewModel(
fun refresh() = load()
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. */
private suspend fun ensureSystem(targetSystemId: String?): Pair<String, Boolean> {
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())
repo.upsertSystem(system)
Analytics.log("System Created", mapOf("name" to name, "source" to "library"))
@@ -95,7 +100,7 @@ class ComponentLibraryViewModel(
affiliateCountryCode = affiliate?.country,
)
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)
}
@@ -123,7 +128,7 @@ class ComponentLibraryViewModel(
affiliateCountryCode = affiliate?.country,
)
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)
}
@@ -157,7 +162,7 @@ class ComponentLibraryViewModel(
powerSourceType = sourceType.rawValue,
)
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)
}

View File

@@ -5,6 +5,10 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
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.analytics.Analytics
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.UnitSystem
import app.voltplan.cable.ui.system.DetailState
import app.voltplan.cable.util.Fmt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
@@ -25,13 +30,12 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.util.Locale
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 {
private const val ENDPOINT = "https://voltplan.app/api/diagram/generate"
private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType()
@@ -39,14 +43,29 @@ object SystemDiagram {
.callTimeout(15, TimeUnit.SECONDS)
.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) {
val payload = buildPayload(state, unit)
val body = Json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA)
val request = Request.Builder()
.url(ENDPOINT)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "image/png")
.post(Json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA))
.post(body)
.build()
runCatching {
@@ -57,18 +76,17 @@ object SystemDiagram {
}.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(
context: Context,
state: DetailState,
unit: UnitSystem,
onError: () -> Unit,
) {
val bitmap = fetch(state, unit)
if (bitmap == null) {
withContext(Dispatchers.Main) { onError() }
return
}
val bitmap = fetchOrFallback(context, state, unit)
val file = withContext(Dispatchers.IO) {
val opaque = flattenOnWhite(bitmap)
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 {
val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
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.energyWattHours
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.util.Fmt
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. */
object SystemOverviewPdf {
suspend fun exportAndShare(context: Context, state: DetailState, unit: UnitSystem) {
// Fetch the wiring diagram first (falls back to no diagram page if unavailable).
val diagram = SystemDiagram.fetch(state, unit)
// Fetch the wiring diagram; falls back to a local block diagram if the API is unreachable.
val diagram = SystemDiagram.fetchOrFallback(context, state, unit)
val file = withContext(Dispatchers.IO) {
val doc = PdfDocument()
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_totalpower), "${Fmt.number(m.totalPower)} W")
summaryLine(w, context.getString(R.string.overview_pdf_summary_totalcurrent), "${Fmt.number(m.totalCurrent)} A")
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalCapacity)} Ah")
summaryLine(w, context.getString(R.string.overview_pdf_summary_batterycapacity), "${Fmt.number(m.totalUsableEnergy)} Wh")
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.
diagram?.let { drawDiagramPage(w, it); w.beginPage() }
// Full-page wiring diagram (always present — API result or local fallback).
drawDiagramPage(w, diagram); w.beginPage()
if (state.loads.isNotEmpty()) {
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 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 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_power)} / ${context.getString(R.string.overview_pdf_load_cable)}", "${Fmt.number(load.power)} W / $gauge")
summaryLine(w, "${context.getString(R.string.overview_pdf_load_vdrop)} / ${context.getString(R.string.overview_pdf_load_fuse)}", String.format(Locale.US, "%.1f%% / %.0f A", vdrop, ElectricalCalculations.recommendedFuse(load.current)))
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_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()
}
}
@@ -71,7 +74,7 @@ object SystemOverviewPdf {
w.text(b.name, 14f, bold = true)
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_chemistry)} / ${context.getString(R.string.overview_pdf_battery_voltage)}", "${b.chemistry.displayName} / ${Fmt.number(b.nominalVoltage)} V")
summaryLine(w, "${context.getString(R.string.overview_pdf_battery_capacity)} / ${context.getString(R.string.overview_pdf_battery_usable)}", "${Fmt.number(b.capacityAmpHours)} Ah / ${Fmt.number(b.usableCapacityAmpHours)} Ah")
summaryLine(w, context.getString(R.string.overview_pdf_battery_energy), "${Fmt.number(b.energyWattHours)} Wh")
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()
}
}

View File

@@ -3,6 +3,7 @@ package app.voltplan.cable.ui.batteries
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.Chemistry
import app.voltplan.cable.data.model.SavedBattery
@@ -14,7 +15,7 @@ import java.util.UUID
import kotlin.math.abs
data class BatteryState(
val name: String = "New Battery",
val name: String = "",
val chemistry: Chemistry = Chemistry.LIFEPO4,
val nominalVoltage: Double = 12.8,
val capacityAmpHours: Double = 100.0,
@@ -33,7 +34,7 @@ data class BatteryState(
}
class BatteryEditorViewModel(
app: CableApplication,
private val app: CableApplication,
private val systemId: String,
batteryId: String?,
) : ViewModel() {
@@ -41,14 +42,25 @@ class BatteryEditorViewModel(
private var id: String = batteryId ?: UUID.randomUUID().toString()
private val isNew = batteryId == null
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())
val state: StateFlow<BatteryState> = _state.asStateFlow()
init {
viewModelScope.launch {
val system = repo.getSystem(systemId)
systemName = system?.name ?: ""
if (batteryId != null) {
repo.getBattery(batteryId)?.let { b ->
affiliateURLString = b.affiliateURLString
affiliateCountryCode = b.affiliateCountryCode
bomCompletedItemIDs = b.bomCompletedItemIDs
_state.value = BatteryState(
name = b.name,
chemistry = Chemistry.fromRaw(b.chemistryRawValue),
@@ -64,14 +76,22 @@ class BatteryEditorViewModel(
)
}
} else {
val color = repo.getSystem(systemId)?.colorName ?: "blue"
val name = repo.uniqueComponentName(systemId, "New Battery")
val color = system?.colorName ?: "blue"
val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_battery_new))
_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) {
_state.value = transform(_state.value)
persist()
@@ -120,13 +140,16 @@ class BatteryEditorViewModel(
iconName = s.iconName,
colorName = s.colorName,
systemId = systemId,
affiliateURLString = affiliateURLString,
affiliateCountryCode = affiliateCountryCode,
bomCompletedItemIDs = bomCompletedItemIDs,
timestamp = System.currentTimeMillis(),
)
viewModelScope.launch {
repo.upsertBattery(battery)
if (isNew && !loggedCreate) {
loggedCreate = true
Analytics.log("Battery Created", mapOf("name" to s.name, "voltage" to s.nominalVoltage, "capacity" to s.capacityAmpHours, "chemistry" to s.chemistry.rawValue))
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.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.LocaleDefaults
import app.voltplan.cable.data.model.PowerSourceType
@@ -17,7 +18,7 @@ import java.util.UUID
enum class PowerEntryMode { CURRENT, POWER }
data class ChargerState(
val name: String = "New Charger",
val name: String = "",
val sourceType: PowerSourceType = PowerSourceType.SHORE,
val inputVoltage: Double = 230.0,
val outputVoltage: Double = 14.2,
@@ -38,7 +39,7 @@ data class ChargerState(
}
class ChargerEditorViewModel(
app: CableApplication,
private val app: CableApplication,
private val systemId: String,
chargerId: String?,
) : ViewModel() {
@@ -46,14 +47,27 @@ class ChargerEditorViewModel(
private var id: String = chargerId ?: UUID.randomUUID().toString()
private val isNew = chargerId == null
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))
val state: StateFlow<ChargerState> = _state.asStateFlow()
init {
viewModelScope.launch {
val system = repo.getSystem(systemId)
systemName = system?.name ?: ""
if (chargerId != null) {
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 current = if (c.maxPowerWatts > 0 && c.outputVoltage > 0) Fmt.roundToTenth(c.maxPowerWatts / c.outputVoltage) else c.maxCurrentAmps
_state.value = ChargerState(
@@ -70,14 +84,22 @@ class ChargerEditorViewModel(
)
}
} else {
val color = repo.getSystem(systemId)?.colorName ?: "orange"
val name = repo.uniqueComponentName(systemId, "New Charger")
val color = system?.colorName ?: "orange"
val name = repo.uniqueComponentName(systemId, app.getString(R.string.default_charger_new))
_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) {
_state.value = transform(_state.value)
persist()
@@ -135,13 +157,17 @@ class ChargerEditorViewModel(
colorName = s.colorName,
systemId = systemId,
timestamp = System.currentTimeMillis(),
remoteIconURLString = remoteIconURLString,
affiliateURLString = affiliateURLString,
affiliateCountryCode = affiliateCountryCode,
bomCompletedItemIDs = bomCompletedItemIDs,
powerSourceType = s.sourceType.rawValue,
)
viewModelScope.launch {
repo.upsertCharger(charger)
if (isNew && !loggedCreate) {
loggedCreate = true
Analytics.log("Charger Created", mapOf("name" to s.name, "output_voltage" to s.outputVoltage, "max_current" to s.maxCurrentAmps, "max_power" to s.maxPowerWatts))
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.text.font.FontWeight
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.theme.componentColor
import app.voltplan.cable.ui.theme.curatedColorNames
@@ -77,7 +79,7 @@ fun AppearanceEditorSheet(
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
TextButton(onClick = { onSave(name, icon, color); onDismiss() }) {
Text("Save", fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.action_save), fontWeight = FontWeight.SemiBold)
}
}
@@ -112,7 +114,7 @@ fun AppearanceEditorSheet(
extra?.invoke()
Text("Icon", style = MaterialTheme.typography.titleSmall)
Text(stringResource(R.string.editor_icon), style = MaterialTheme.typography.titleSmall)
GridRows(items = icons, columns = 5) { symbol ->
val selected = symbol == icon
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 ->
val c = componentColor(colorName)
Box(

View File

@@ -20,9 +20,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.voltplan.cable.R
import app.voltplan.cable.util.Fmt
import kotlin.math.abs
@@ -113,8 +115,8 @@ fun ValueEditDialog(
}
},
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()
LaunchedEffect(Unit) {
Analytics.log(
"Component Library Opened",
mapOf(
"source" to if (targetSystemId != null) "system" else "systems-list",
"type" to libraryType.typeValue,
),
val props = mutableMapOf<String, Any>(
"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(

View File

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

View File

@@ -138,7 +138,10 @@ fun SystemDetailScreen(
system.name,
fontWeight = FontWeight.SemiBold,
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 ?: "")))
}
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) {
val current = state.value.system ?: return
viewModelScope.launch {
repo.upsertSystem(current.copy(name = name, location = location, iconName = iconName, colorName = colorName))
Analytics.log("System Updated", mapOf("name" to name))
}
}

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.Delete
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -28,6 +29,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -59,6 +61,28 @@ fun SystemsScreen(
vm: SystemsViewModel = viewModel(),
) {
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(
topBar = {
@@ -84,7 +108,7 @@ fun SystemsScreen(
SystemsOnboarding(
modifier = Modifier.padding(padding),
onCreate = { name ->
vm.createSystem(name, source = "onboarding", randomColor = true, onCreated = onOpenSystem)
vm.createSystem(name, source = "named", randomColor = true, onCreated = onOpenSystem)
},
)
} else {
@@ -99,7 +123,7 @@ fun SystemsScreen(
vm.logOpen(summary, "list")
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 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(
modifier = modifier
.fillMaxSize()

View File

@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.voltplan.cable.CableApplication
import app.voltplan.cable.R
import app.voltplan.cable.analytics.Analytics
import app.voltplan.cable.data.model.ElectricalSystem
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. */
fun createSystem(preferredName: String?, source: String, randomColor: Boolean, onCreated: (String) -> Unit) {
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 color = if (randomColor) SystemIconMapper.colorOptions.random() else "blue"
val system = ElectricalSystem(

View File

@@ -76,6 +76,8 @@
<!-- Batteries -->
<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_capacity">Kapazität</string>
<string name="battery_metric_usable_capacity">Nutzbare Kapazität</string>
@@ -263,4 +265,18 @@
<!-- Misc -->
<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>

View File

@@ -76,6 +76,8 @@
<!-- Batteries -->
<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_capacity">Capacidad</string>
<string name="battery_metric_usable_capacity">Capacidad utilizable</string>
@@ -263,4 +265,18 @@
<!-- Misc -->
<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>

View File

@@ -76,6 +76,8 @@
<!-- Batteries -->
<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_capacity">Capacité</string>
<string name="battery_metric_usable_capacity">Capacité utilisable</string>
@@ -263,4 +265,18 @@
<!-- Misc -->
<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>

View File

@@ -76,6 +76,8 @@
<!-- Batteries -->
<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_capacity">Capaciteit</string>
<string name="battery_metric_usable_capacity">Beschikbare capaciteit</string>
@@ -263,4 +265,18 @@
<!-- Misc -->
<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>

View File

@@ -76,6 +76,8 @@
<!-- Batteries -->
<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_capacity">Capacity</string>
<string name="battery_metric_usable_capacity">Usable Capacity</string>
@@ -263,4 +265,18 @@
<!-- Misc -->
<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>