Compare commits
2 Commits
10dc0e4fa9
...
97a9d3903c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a9d3903c | ||
|
|
45a462295d |
@@ -405,7 +405,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -440,7 +440,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
||||
@@ -180,6 +180,24 @@
|
||||
"overview.runtime.title" = "Estimated runtime";
|
||||
"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime.";
|
||||
"overview.system.header.title" = "System Overview";
|
||||
"overview.bom.title" = "Bill of Materials";
|
||||
"overview.bom.subtitle" = "Tap to review components";
|
||||
"overview.bom.unavailable" = "Add loads to generate components.";
|
||||
"overview.bom.placeholder.short" = "Add loads";
|
||||
"overview.chargetime.title" = "Estimated charge time";
|
||||
"overview.chargetime.subtitle" = "At combined charge rate";
|
||||
"overview.chargetime.unavailable" = "Add chargers and battery capacity to estimate.";
|
||||
"overview.chargetime.placeholder.short" = "Add chargers";
|
||||
"overview.goal.prefix" = "Goal";
|
||||
"overview.goal.label" = "Goal %@";
|
||||
"overview.goal.clear" = "Remove Goal";
|
||||
"overview.goal.cancel" = "Cancel";
|
||||
"overview.goal.save" = "Save";
|
||||
"overview.runtime.goal.title" = "Runtime Goal";
|
||||
"overview.runtime.placeholder.short" = "Add capacity";
|
||||
"overview.chargetime.goal.title" = "Charge Goal";
|
||||
"sample.battery.rv.name" = "LiFePO4 house bank";
|
||||
"sample.battery.workshop.name" = "Workbench backup battery";
|
||||
"sample.charger.dcdc.name" = "DC-DC charger";
|
||||
"sample.charger.shore.name" = "Shore power charger";
|
||||
"sample.charger.workbench.name" = "Workbench charger";
|
||||
|
||||
@@ -112,13 +112,24 @@ class ElectricalSystem {
|
||||
var timestamp: Date = Date()
|
||||
var iconName: String = "building.2"
|
||||
var colorName: String = "blue"
|
||||
var targetRuntimeHours: Double?
|
||||
var targetChargeTimeHours: Double?
|
||||
|
||||
init(name: String, location: String = "", iconName: String = "building.2", colorName: String = "blue") {
|
||||
init(
|
||||
name: String,
|
||||
location: String = "",
|
||||
iconName: String = "building.2",
|
||||
colorName: String = "blue",
|
||||
targetRuntimeHours: Double? = nil,
|
||||
targetChargeTimeHours: Double? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.timestamp = Date()
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.targetRuntimeHours = targetRuntimeHours
|
||||
self.targetChargeTimeHours = targetChargeTimeHours
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,7 @@ struct LoadsView: View {
|
||||
onSelectChargers: { selectedComponentTab = .chargers },
|
||||
onCreateLoad: { createNewLoad() },
|
||||
onBrowseLibrary: { showingComponentLibrary = true },
|
||||
onShowBillOfMaterials: { showingSystemBOM = true },
|
||||
onCreateBattery: { startBatteryConfiguration() },
|
||||
onCreateCharger: { startChargerConfiguration() }
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ struct OnboardingInfoView: View {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Spacer()
|
||||
Button(action: onPrimaryAction) {
|
||||
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -76,6 +77,7 @@ struct OnboardingInfoView: View {
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.frame(minHeight: 140)
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SystemOverviewView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var activeStatus: LoadConfigurationStatus?
|
||||
@State private var suppressLoadNavigation = false
|
||||
@State private var showingRuntimeGoalEditor = false
|
||||
@State private var showingChargeGoalEditor = false
|
||||
@State private var runtimeGoalDraftHours: Double = 4
|
||||
@State private var chargeGoalDraftHours: Double = 4
|
||||
let system: ElectricalSystem
|
||||
let loads: [SavedLoad]
|
||||
let batteries: [SavedBattery]
|
||||
@@ -13,108 +19,205 @@ struct SystemOverviewView: View {
|
||||
let onSelectChargers: () -> Void
|
||||
let onCreateLoad: () -> Void
|
||||
let onBrowseLibrary: () -> Void
|
||||
let onShowBillOfMaterials: () -> Void
|
||||
let onCreateBattery: () -> Void
|
||||
let onCreateCharger: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
systemCard
|
||||
loadsCard
|
||||
batteriesCard
|
||||
chargersCard
|
||||
VStack(spacing: 0) {
|
||||
systemCard
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
loadsCard
|
||||
batteriesCard
|
||||
chargersCard
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.sheet(isPresented: $showingRuntimeGoalEditor) {
|
||||
GoalEditorSheet(
|
||||
title: runtimeGoalSheetTitle,
|
||||
tint: .orange,
|
||||
value: $runtimeGoalDraftHours,
|
||||
minimum: Self.minimumGoalHours,
|
||||
maximum: Self.maximumGoalHours,
|
||||
step: Self.goalStepHours,
|
||||
cancelTitle: goalCancelTitle,
|
||||
saveTitle: goalSaveTitle,
|
||||
clearTitle: goalClearTitle,
|
||||
showsClear: system.targetRuntimeHours != nil,
|
||||
formattedDurationProvider: { formattedDuration(hours: $0) },
|
||||
onSave: { saveRuntimeGoal(hours: $0) },
|
||||
onClear: system.targetRuntimeHours != nil ? { clearRuntimeGoal() } : nil
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingChargeGoalEditor) {
|
||||
GoalEditorSheet(
|
||||
title: chargeGoalSheetTitle,
|
||||
tint: .blue,
|
||||
value: $chargeGoalDraftHours,
|
||||
minimum: Self.minimumGoalHours,
|
||||
maximum: Self.maximumGoalHours,
|
||||
step: Self.goalStepHours,
|
||||
cancelTitle: goalCancelTitle,
|
||||
saveTitle: goalSaveTitle,
|
||||
clearTitle: goalClearTitle,
|
||||
showsClear: system.targetChargeTimeHours != nil,
|
||||
formattedDurationProvider: { formattedDuration(hours: $0) },
|
||||
onSave: { saveChargeGoal(hours: $0) },
|
||||
onClear: system.targetChargeTimeHours != nil ? { clearChargeGoal() } : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var systemCard: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
Text(systemOverviewTitle)
|
||||
.font(.headline.weight(.semibold))
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.componentColor(named: system.colorName))
|
||||
.frame(width: 54, height: 54)
|
||||
Image(systemName: system.iconName)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(system.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
if !system.location.isEmpty {
|
||||
Label(system.location, systemImage: "mappin.and.ellipse")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(spacing: 14) {
|
||||
overviewMetricRow(
|
||||
title: runtimeTitle,
|
||||
subtitle: runtimeSubtitle,
|
||||
icon: "clock.arrow.circlepath",
|
||||
tint: .orange,
|
||||
value: formattedRuntime,
|
||||
placeholder: runtimePlaceholderSummary,
|
||||
goal: runtimeGoalValueText,
|
||||
actualHours: estimatedRuntimeHours,
|
||||
goalHours: system.targetRuntimeHours,
|
||||
progressFraction: nil,
|
||||
hasValue: formattedRuntime != nil,
|
||||
action: openRuntimeGoalEditor
|
||||
)
|
||||
|
||||
overviewMetricRow(
|
||||
title: bomTitle,
|
||||
subtitle: bomSubtitle,
|
||||
icon: "list.bullet.rectangle",
|
||||
tint: .purple,
|
||||
value: formattedBOMCompletedCount,
|
||||
placeholder: bomPlaceholderSummary,
|
||||
goal: formattedBOMTotalCount,
|
||||
actualHours: nil,
|
||||
goalHours: nil,
|
||||
progressFraction: bomCompletionFraction,
|
||||
hasValue: bomItemsCount > 0,
|
||||
action: onShowBillOfMaterials
|
||||
)
|
||||
|
||||
overviewMetricRow(
|
||||
title: chargeTimeTitle,
|
||||
subtitle: chargeTimeSubtitle,
|
||||
icon: "bolt.fill",
|
||||
tint: .blue,
|
||||
value: formattedChargeTime,
|
||||
placeholder: chargeTimePlaceholderSummary,
|
||||
goal: chargeGoalValueText,
|
||||
actualHours: estimatedChargeHours,
|
||||
goalHours: system.targetChargeTimeHours,
|
||||
progressFraction: nil,
|
||||
hasValue: formattedChargeTime != nil,
|
||||
action: openChargeGoalEditor
|
||||
)
|
||||
}
|
||||
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
summaryMetric(
|
||||
icon: "square.stack.3d.up",
|
||||
label: loadsCountLabel,
|
||||
value: "\(loads.count)",
|
||||
tint: .blue
|
||||
).frame(maxWidth: .infinity, alignment: .leading)
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: batteryCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .green
|
||||
).frame(maxWidth: .infinity, alignment: .leading)
|
||||
summaryMetric(
|
||||
icon: "bolt.fill",
|
||||
label: chargerCountLabel,
|
||||
value: "\(chargers.count)",
|
||||
tint: .orange
|
||||
).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
summaryMetric(
|
||||
icon: "square.stack.3d.up",
|
||||
label: loadsCountLabel,
|
||||
value: "\(loads.count)",
|
||||
tint: .blue
|
||||
).frame(maxWidth: .infinity, alignment: .leading)
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: batteryCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .green
|
||||
).frame(maxWidth: .infinity, alignment: .leading)
|
||||
summaryMetric(
|
||||
icon: "bolt.fill",
|
||||
label: chargerCountLabel,
|
||||
value: "\(chargers.count)",
|
||||
tint: .orange
|
||||
).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
runtimeSection
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func overviewMetricRow(
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
icon: String,
|
||||
tint: Color,
|
||||
value: String?,
|
||||
placeholder: String?,
|
||||
goal: String?,
|
||||
actualHours: Double?,
|
||||
goalHours: Double?,
|
||||
progressFraction: Double?,
|
||||
hasValue: Bool,
|
||||
action: (() -> Void)? = nil
|
||||
) -> some View {
|
||||
let content = VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(tint.opacity(0.18))
|
||||
.frame(width: 24, height: 24)
|
||||
.overlay {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(value ?? "—")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(hasValue ? Color.primary : Color.secondary)
|
||||
.lineLimit(1)
|
||||
if let goal {
|
||||
Text("\(goalPrefix) \(goal)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
progressBar(
|
||||
progress: adjustedProgress(for: progressFraction, actualHours: actualHours, goalHours: goalHours),
|
||||
tint: tint
|
||||
)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
|
||||
let paddedContent = content
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
if let action {
|
||||
Button(action: action) {
|
||||
paddedContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
paddedContent
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadsCard: some View {
|
||||
if loads.isEmpty {
|
||||
@@ -290,7 +393,6 @@ struct SystemOverviewView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Button(action: onCreateBattery) {
|
||||
Label(batteryEmptyCreateAction, systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -555,37 +657,6 @@ struct SystemOverviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var runtimeSection: some View {
|
||||
if let runtimeText = formattedRuntime {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color.orange)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(runtimeTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(runtimeSubtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(runtimeText)
|
||||
.font(.title3.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.orange.opacity(0.12))
|
||||
)
|
||||
} else if shouldShowRuntimeHint {
|
||||
Text(runtimeUnavailableText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusBanner(for status: LoadConfigurationStatus) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: status.symbol)
|
||||
@@ -604,6 +675,41 @@ struct SystemOverviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var bomCompletionFraction: Double? {
|
||||
guard bomItemsCount > 0 else { return nil }
|
||||
return Double(completedBOMItemCount) / Double(bomItemsCount)
|
||||
}
|
||||
|
||||
private var completedBOMItemCount: Int {
|
||||
settledLoads.reduce(into: Set<String>()) { partialResult, load in
|
||||
load.bomCompletedItemIDs.forEach { partialResult.insert($0) }
|
||||
}.count
|
||||
}
|
||||
|
||||
private var bomItemsCount: Int {
|
||||
settledLoads.isEmpty ? 0 : settledLoads.count * Self.bomItemsPerLoad
|
||||
}
|
||||
|
||||
private var settledLoads: [SavedLoad] {
|
||||
loads.filter { $0.crossSection > 0 && $0.length > 0 }
|
||||
}
|
||||
|
||||
private func progressBar(progress: CGFloat?, tint: Color) -> some View {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(Color(.tertiarySystemFill))
|
||||
.frame(height: 4)
|
||||
.frame(maxWidth: .infinity)
|
||||
.overlay(alignment: .leading) {
|
||||
if let progress {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(tint)
|
||||
.frame(maxWidth: .infinity)
|
||||
.scaleEffect(x: max(min(progress, 1), 0), y: 1, anchor: .leading)
|
||||
.animation(.easeInOut(duration: 0.25), value: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedCurrent(_ value: Double) -> String {
|
||||
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||
return "\(numberString) A"
|
||||
@@ -614,6 +720,10 @@ struct SystemOverviewView: View {
|
||||
return "\(numberString) W"
|
||||
}
|
||||
|
||||
private func numberString(for value: Double) -> String {
|
||||
Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
|
||||
}
|
||||
|
||||
private func formattedChargerOutput(_ value: Double?) -> String {
|
||||
guard let value, value > 0 else { return "—" }
|
||||
return formattedValue(value, unit: "V")
|
||||
@@ -624,6 +734,32 @@ struct SystemOverviewView: View {
|
||||
return "\(numberString) \(unit)"
|
||||
}
|
||||
|
||||
private func progressFraction(actualHours: Double?, goalHours: Double?) -> CGFloat {
|
||||
guard let actualHours, let goalHours, goalHours > 0 else { return 0 }
|
||||
let ratio = actualHours / goalHours
|
||||
return CGFloat(max(min(ratio, 1), 0))
|
||||
}
|
||||
|
||||
private func adjustedProgress(for explicit: Double?, actualHours: Double?, goalHours: Double?) -> CGFloat? {
|
||||
guard let ratio = progressRatio(explicitProgress: explicit, actualHours: actualHours, goalHours: goalHours) else { return nil }
|
||||
if explicit != nil { return ratio }
|
||||
guard let actual = actualHours, let goal = goalHours, goal > 0 else { return ratio }
|
||||
if goal == system.targetChargeTimeHours {
|
||||
let value = goal / max(actual, 0.0001)
|
||||
return CGFloat(max(min(value, 1), 0))
|
||||
}
|
||||
return ratio
|
||||
}
|
||||
|
||||
private func progressRatio(explicitProgress: Double?, actualHours: Double?, goalHours: Double?) -> CGFloat? {
|
||||
if let explicitProgress {
|
||||
return CGFloat(max(min(explicitProgress, 1), 0))
|
||||
}
|
||||
|
||||
guard goalHours != nil else { return nil }
|
||||
return progressFraction(actualHours: actualHours, goalHours: goalHours)
|
||||
}
|
||||
|
||||
private var estimatedRuntimeHours: Double? {
|
||||
guard totalPower > 0, totalUsableEnergy > 0 else { return nil }
|
||||
let hours = totalUsableEnergy / totalPower
|
||||
@@ -639,9 +775,99 @@ struct SystemOverviewView: View {
|
||||
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
|
||||
return "\(numberString) h"
|
||||
}
|
||||
|
||||
private var formattedChargeTime: String? {
|
||||
guard let hours = estimatedChargeHours else { return nil }
|
||||
let seconds = hours * 3600
|
||||
if let formatted = Self.runtimeFormatter.string(from: seconds) {
|
||||
return formatted
|
||||
}
|
||||
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
|
||||
return "\(numberString) h"
|
||||
}
|
||||
|
||||
private var shouldShowRuntimeHint: Bool {
|
||||
!batteries.isEmpty && !loads.isEmpty && formattedRuntime == nil
|
||||
private var estimatedChargeHours: Double? {
|
||||
guard totalUsableCapacity > 0, totalChargerCurrent > 0 else { return nil }
|
||||
let hours = totalUsableCapacity / totalChargerCurrent
|
||||
return hours.isFinite && hours > 0 ? hours : nil
|
||||
}
|
||||
|
||||
private func openRuntimeGoalEditor() {
|
||||
runtimeGoalDraftHours = defaultGoalHours(current: system.targetRuntimeHours, fallback: estimatedRuntimeHours)
|
||||
showingRuntimeGoalEditor = true
|
||||
}
|
||||
|
||||
private func openChargeGoalEditor() {
|
||||
chargeGoalDraftHours = defaultGoalHours(current: system.targetChargeTimeHours, fallback: estimatedChargeHours)
|
||||
showingChargeGoalEditor = true
|
||||
}
|
||||
|
||||
private func saveRuntimeGoal(hours: Double) {
|
||||
let adjusted = clampedGoal(hours)
|
||||
system.targetRuntimeHours = adjusted
|
||||
runtimeGoalDraftHours = adjusted
|
||||
persistSystemChanges()
|
||||
showingRuntimeGoalEditor = false
|
||||
}
|
||||
|
||||
private func saveChargeGoal(hours: Double) {
|
||||
let adjusted = clampedGoal(hours)
|
||||
system.targetChargeTimeHours = adjusted
|
||||
chargeGoalDraftHours = adjusted
|
||||
persistSystemChanges()
|
||||
showingChargeGoalEditor = false
|
||||
}
|
||||
|
||||
private func clearRuntimeGoal() {
|
||||
system.targetRuntimeHours = nil
|
||||
persistSystemChanges()
|
||||
runtimeGoalDraftHours = defaultGoalHours(current: nil, fallback: estimatedRuntimeHours)
|
||||
showingRuntimeGoalEditor = false
|
||||
}
|
||||
|
||||
private func clearChargeGoal() {
|
||||
system.targetChargeTimeHours = nil
|
||||
persistSystemChanges()
|
||||
chargeGoalDraftHours = defaultGoalHours(current: nil, fallback: estimatedChargeHours)
|
||||
showingChargeGoalEditor = false
|
||||
}
|
||||
|
||||
private func persistSystemChanges() {
|
||||
guard modelContext.hasChanges else { return }
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to save system goal: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private func defaultGoalHours(current: Double?, fallback: Double?) -> Double {
|
||||
if let current, current >= Self.minimumGoalHours {
|
||||
return clampedGoal(current)
|
||||
}
|
||||
if let fallback, fallback >= Self.minimumGoalHours {
|
||||
return clampedGoal(fallback)
|
||||
}
|
||||
return Self.fallbackGoalHours
|
||||
}
|
||||
|
||||
private func clampedGoal(_ hours: Double) -> Double {
|
||||
guard hours.isFinite else { return Self.fallbackGoalHours }
|
||||
let rounded = (hours / Self.goalStepHours).rounded() * Self.goalStepHours
|
||||
return min(max(rounded, Self.minimumGoalHours), Self.maximumGoalHours)
|
||||
}
|
||||
|
||||
private func formattedDuration(hours: Double) -> String {
|
||||
let seconds = hours * 3600
|
||||
if let formatted = Self.runtimeFormatter.string(from: seconds) {
|
||||
return formatted
|
||||
}
|
||||
|
||||
let numberString = Self.numberFormatter.string(from: NSNumber(value: hours)) ?? String(format: "%.1f", hours)
|
||||
return "\(numberString) h"
|
||||
}
|
||||
|
||||
private var loadsSummaryTitle: String {
|
||||
@@ -880,6 +1106,83 @@ struct SystemOverviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var bomTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.bom.title",
|
||||
bundle: .main,
|
||||
value: "Bill of Materials",
|
||||
comment: "Title for BOM metric card in the system overview"
|
||||
)
|
||||
}
|
||||
|
||||
private var bomSubtitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.bom.subtitle",
|
||||
bundle: .main,
|
||||
value: "Tap to review components",
|
||||
comment: "Subtitle describing the BOM metric card interaction"
|
||||
)
|
||||
}
|
||||
|
||||
private var bomPlaceholderSummary: String {
|
||||
NSLocalizedString(
|
||||
"overview.bom.placeholder.short",
|
||||
bundle: .main,
|
||||
value: "Add loads",
|
||||
comment: "Short placeholder shown when no BOM data is available"
|
||||
)
|
||||
}
|
||||
|
||||
private var formattedBOMCompletedCount: String? {
|
||||
guard bomItemsCount > 0 else { return nil }
|
||||
return numberString(for: Double(completedBOMItemCount))
|
||||
}
|
||||
|
||||
private var formattedBOMTotalCount: String? {
|
||||
guard bomItemsCount > 0 else { return nil }
|
||||
return numberString(for: Double(bomItemsCount))
|
||||
}
|
||||
|
||||
private var chargeTimeTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.chargetime.title",
|
||||
bundle: .main,
|
||||
value: "Estimated charge time",
|
||||
comment: "Title for the charge time metric card"
|
||||
)
|
||||
}
|
||||
|
||||
private var chargeTimeSubtitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.chargetime.subtitle",
|
||||
bundle: .main,
|
||||
value: "At combined charge rate",
|
||||
comment: "Subtitle describing charge time assumptions"
|
||||
)
|
||||
}
|
||||
|
||||
private var chargeTimePlaceholderSummary: String {
|
||||
NSLocalizedString(
|
||||
"overview.chargetime.placeholder.short",
|
||||
bundle: .main,
|
||||
value: "Add chargers",
|
||||
comment: "Short placeholder shown when charge time cannot be calculated"
|
||||
)
|
||||
}
|
||||
|
||||
private var chargeGoalValueText: String? {
|
||||
formattedGoalValue(for: system.targetChargeTimeHours)
|
||||
}
|
||||
|
||||
private var goalPrefix: String {
|
||||
NSLocalizedString(
|
||||
"overview.goal.prefix",
|
||||
bundle: .main,
|
||||
value: "Goal",
|
||||
comment: "Prefix displayed before goal value"
|
||||
)
|
||||
}
|
||||
|
||||
private var runtimeTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.runtime.title",
|
||||
@@ -898,12 +1201,66 @@ struct SystemOverviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var runtimeUnavailableText: String {
|
||||
private var runtimePlaceholderSummary: String {
|
||||
NSLocalizedString(
|
||||
"overview.runtime.unavailable",
|
||||
"overview.runtime.placeholder.short",
|
||||
bundle: .main,
|
||||
value: "Add battery capacity and load power to estimate runtime.",
|
||||
comment: "Message shown when runtime cannot be calculated"
|
||||
value: "Add capacity",
|
||||
comment: "Short placeholder shown when runtime cannot be calculated"
|
||||
)
|
||||
}
|
||||
|
||||
private var runtimeGoalValueText: String? {
|
||||
formattedGoalValue(for: system.targetRuntimeHours)
|
||||
}
|
||||
|
||||
private func formattedGoalValue(for hours: Double?) -> String? {
|
||||
guard let hours else { return nil }
|
||||
return formattedDuration(hours: hours)
|
||||
}
|
||||
|
||||
private var runtimeGoalSheetTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.runtime.goal.title",
|
||||
bundle: .main,
|
||||
value: "Runtime Goal",
|
||||
comment: "Navigation title for editing the runtime goal"
|
||||
)
|
||||
}
|
||||
|
||||
private var chargeGoalSheetTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.chargetime.goal.title",
|
||||
bundle: .main,
|
||||
value: "Charge Goal",
|
||||
comment: "Navigation title for editing the charge time goal"
|
||||
)
|
||||
}
|
||||
|
||||
private var goalClearTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.goal.clear",
|
||||
bundle: .main,
|
||||
value: "Remove Goal",
|
||||
comment: "Button title to clear an active goal"
|
||||
)
|
||||
}
|
||||
|
||||
private var goalCancelTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.goal.cancel",
|
||||
bundle: .main,
|
||||
value: "Cancel",
|
||||
comment: "Button title to cancel goal editing"
|
||||
)
|
||||
}
|
||||
|
||||
private var goalSaveTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.goal.save",
|
||||
bundle: .main,
|
||||
value: "Save",
|
||||
comment: "Button title to save goal editing"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -923,6 +1280,13 @@ struct SystemOverviewView: View {
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let minimumGoalHours: Double = 0.5
|
||||
private static let maximumGoalHours: Double = 240
|
||||
private static let goalStepHours: Double = 0.25
|
||||
private static let fallbackGoalHours: Double = 4
|
||||
|
||||
private static let bomItemsPerLoad = 5
|
||||
|
||||
private enum BatteryWarning {
|
||||
case voltage(count: Int)
|
||||
case capacity(count: Int)
|
||||
@@ -966,6 +1330,77 @@ struct SystemOverviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct GoalEditorSheet: View {
|
||||
let title: String
|
||||
let tint: Color
|
||||
@Binding var value: Double
|
||||
let minimum: Double
|
||||
let maximum: Double
|
||||
let step: Double
|
||||
let cancelTitle: String
|
||||
let saveTitle: String
|
||||
let clearTitle: String
|
||||
let showsClear: Bool
|
||||
let formattedDurationProvider: (Double) -> String
|
||||
let onSave: (Double) -> Void
|
||||
let onClear: (() -> Void)?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Stepper(value: $value, in: minimum...maximum, step: step) {
|
||||
Label {
|
||||
Text(formattedDurationProvider(value))
|
||||
.font(.title3.weight(.semibold))
|
||||
} icon: {
|
||||
Image(systemName: "flag.checkered")
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if showsClear, let onClear {
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
onClear()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text(clearTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(tint)
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(cancelTitle) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(saveTitle) {
|
||||
onSave(clampedValue)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!value.isFinite || value < minimum)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var clampedValue: Double {
|
||||
min(max(value, minimum), maximum)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("SystemOverview – Populated") {
|
||||
let system = ElectricalSystem(
|
||||
name: "12V DC System",
|
||||
@@ -973,6 +1408,8 @@ struct SystemOverviewView: View {
|
||||
iconName: "bolt.circle.fill",
|
||||
colorName: "blue"
|
||||
)
|
||||
//system.targetRuntimeHours = 8
|
||||
//system.targetChargeTimeHours = 6
|
||||
|
||||
let loads: [SavedLoad] = [
|
||||
SavedLoad(
|
||||
@@ -1047,10 +1484,12 @@ struct SystemOverviewView: View {
|
||||
onSelectChargers: {},
|
||||
onCreateLoad: {},
|
||||
onBrowseLibrary: {},
|
||||
onShowBillOfMaterials: {},
|
||||
onCreateBattery: {},
|
||||
onCreateCharger: {}
|
||||
)
|
||||
.padding()
|
||||
.modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true)
|
||||
}
|
||||
|
||||
#Preview("SystemOverview – Empty States") {
|
||||
@@ -1071,8 +1510,10 @@ struct SystemOverviewView: View {
|
||||
onSelectChargers: {},
|
||||
onCreateLoad: {},
|
||||
onBrowseLibrary: {},
|
||||
onShowBillOfMaterials: {},
|
||||
onCreateBattery: {},
|
||||
onCreateCharger: {}
|
||||
)
|
||||
.padding()
|
||||
.modelContainer(for: [ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self], inMemory: true)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,38 @@ private extension UITestSampleData {
|
||||
|
||||
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
|
||||
|
||||
let vanHouseBattery = SavedBattery(
|
||||
name: String(localized: "sample.battery.rv.name", comment: "Sample data battery name for the adventure van system"),
|
||||
nominalVoltage: 12.8,
|
||||
capacityAmpHours: 200.0,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
chargeVoltage: 14.4,
|
||||
cutOffVoltage: 10.8,
|
||||
minimumTemperatureCelsius: -20,
|
||||
maximumTemperatureCelsius: 60,
|
||||
iconName: "battery.100.bolt",
|
||||
colorName: "purple",
|
||||
system: adventureVan
|
||||
)
|
||||
vanHouseBattery.timestamp = Date(timeIntervalSinceReferenceDate: 1250)
|
||||
|
||||
let workshopBackupBattery = SavedBattery(
|
||||
name: String(localized: "sample.battery.workshop.name", comment: "Sample data battery name for the workshop system"),
|
||||
nominalVoltage: 24.0,
|
||||
capacityAmpHours: 100.0,
|
||||
chemistry: .agm,
|
||||
chargeVoltage: 28.8,
|
||||
cutOffVoltage: 21.0,
|
||||
minimumTemperatureCelsius: -10,
|
||||
maximumTemperatureCelsius: 50,
|
||||
iconName: "battery.75",
|
||||
colorName: "gray",
|
||||
system: workshopBench
|
||||
)
|
||||
workshopBackupBattery.timestamp = Date(timeIntervalSinceReferenceDate: 2300)
|
||||
|
||||
[vanHouseBattery, workshopBackupBattery].forEach { context.insert($0) }
|
||||
|
||||
let shoreCharger = SavedCharger(
|
||||
name: String(localized: "sample.charger.shore.name", comment: "Sample data name for a shore power charger"),
|
||||
inputVoltage: 230.0,
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
"loads.metric.fuse" = "Sicherung";
|
||||
"loads.metric.length" = "Länge";
|
||||
"loads.onboarding.subtitle" = "Statte dein System mit Verbrauchern aus und lass **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen übernehmen.";
|
||||
"loads.onboarding.title" = "Füge deinen ersten Verbraucher hinzu";
|
||||
"loads.onboarding.title" = "Erstelle deinen ersten Verbraucher";
|
||||
"loads.overview.empty.create" = "Verbraucher hinzufügen";
|
||||
"loads.overview.empty.library" = "Bibliothek durchsuchen";
|
||||
"loads.overview.empty.message" = "Füge einen Verbraucher hinzu, um dein System einzurichten.";
|
||||
@@ -240,6 +240,24 @@
|
||||
"overview.runtime.title" = "Geschätzte Laufzeit";
|
||||
"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen.";
|
||||
"overview.system.header.title" = "Systemübersicht";
|
||||
"overview.bom.title" = "Stückliste";
|
||||
"overview.bom.subtitle" = "Tippe, um Komponenten zu prüfen";
|
||||
"overview.bom.unavailable" = "Füge Verbraucher hinzu, um Komponenten zu erzeugen.";
|
||||
"overview.bom.placeholder.short" = "Verbraucher hinzufügen";
|
||||
"overview.chargetime.title" = "Geschätzte Ladezeit";
|
||||
"overview.chargetime.subtitle" = "Bei kombinierter Laderate";
|
||||
"overview.chargetime.unavailable" = "Füge Ladegeräte und Batteriekapazität hinzu, um eine Schätzung zu erhalten.";
|
||||
"overview.chargetime.placeholder.short" = "Ladegeräte hinzufügen";
|
||||
"overview.goal.prefix" = "Ziel";
|
||||
"overview.goal.label" = "Ziel %@";
|
||||
"overview.goal.clear" = "Ziel entfernen";
|
||||
"overview.goal.cancel" = "Abbrechen";
|
||||
"overview.goal.save" = "Speichern";
|
||||
"overview.runtime.goal.title" = "Laufzeit-Ziel";
|
||||
"overview.chargetime.goal.title" = "Ladezeit-Ziel";
|
||||
"overview.runtime.placeholder.short" = "Kapazität hinzufügen";
|
||||
"sample.battery.rv.name" = "LiFePO4-Bordbatterie";
|
||||
"sample.battery.workshop.name" = "Werkbank-Reservebatterie";
|
||||
"sample.charger.dcdc.name" = "DC-DC-Ladegerät";
|
||||
"sample.charger.shore.name" = "Landstrom-Ladegerät";
|
||||
"sample.charger.workbench.name" = "Werkbank-Ladegerät";
|
||||
|
||||
@@ -176,6 +176,22 @@
|
||||
"overview.runtime.title" = "Autonomía estimada";
|
||||
"overview.runtime.subtitle" = "Con la carga actual";
|
||||
"overview.runtime.unavailable" = "Añade capacidad de batería y potencia de carga para estimar la autonomía.";
|
||||
"overview.bom.title" = "Lista de materiales";
|
||||
"overview.bom.subtitle" = "Pulsa para revisar los componentes";
|
||||
"overview.bom.unavailable" = "Añade cargas para generar componentes.";
|
||||
"overview.bom.placeholder.short" = "Añadir cargas";
|
||||
"overview.chargetime.title" = "Tiempo de carga estimado";
|
||||
"overview.chargetime.subtitle" = "Con la tasa de carga combinada";
|
||||
"overview.chargetime.unavailable" = "Añade cargadores y capacidad de batería para calcularlo.";
|
||||
"overview.chargetime.placeholder.short" = "Añadir cargadores";
|
||||
"overview.goal.prefix" = "Objetivo";
|
||||
"overview.goal.label" = "Objetivo %@";
|
||||
"overview.goal.clear" = "Eliminar objetivo";
|
||||
"overview.goal.cancel" = "Cancelar";
|
||||
"overview.goal.save" = "Guardar";
|
||||
"overview.runtime.goal.title" = "Objetivo de autonomía";
|
||||
"overview.chargetime.goal.title" = "Objetivo de carga";
|
||||
"overview.runtime.placeholder.short" = "Añadir capacidad";
|
||||
"battery.bank.warning.voltage.short" = "Voltaje";
|
||||
"battery.bank.warning.capacity.short" = "Capacidad";
|
||||
|
||||
@@ -293,6 +309,8 @@
|
||||
"chargers.onboarding.subtitle" = "Lleva el control del cargador de costa, los boosters y los controladores solares para saber cuánta potencia de carga tienes.";
|
||||
"chargers.onboarding.primary" = "Crear cargador";
|
||||
|
||||
"sample.battery.rv.name" = "Banco LiFePO4 de servicio";
|
||||
"sample.battery.workshop.name" = "Batería de respaldo del banco de trabajo";
|
||||
"sample.charger.shore.name" = "Cargador de costa";
|
||||
"sample.charger.dcdc.name" = "Cargador DC-DC";
|
||||
"sample.charger.workbench.name" = "Cargador de banco de trabajo";
|
||||
|
||||
@@ -176,6 +176,22 @@
|
||||
"overview.runtime.title" = "Autonomie estimée";
|
||||
"overview.runtime.subtitle" = "Avec la charge actuelle";
|
||||
"overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer l’autonomie.";
|
||||
"overview.bom.title" = "Liste de matériel";
|
||||
"overview.bom.subtitle" = "Touchez pour consulter les composants";
|
||||
"overview.bom.unavailable" = "Ajoutez des charges pour générer des composants.";
|
||||
"overview.bom.placeholder.short" = "Ajouter des charges";
|
||||
"overview.chargetime.title" = "Temps de charge estimé";
|
||||
"overview.chargetime.subtitle" = "Au débit de charge combiné";
|
||||
"overview.chargetime.unavailable" = "Ajoutez des chargeurs et de la capacité batterie pour estimer.";
|
||||
"overview.chargetime.placeholder.short" = "Ajouter des chargeurs";
|
||||
"overview.goal.prefix" = "Objectif";
|
||||
"overview.goal.label" = "Objectif %@";
|
||||
"overview.goal.clear" = "Supprimer l'objectif";
|
||||
"overview.goal.cancel" = "Annuler";
|
||||
"overview.goal.save" = "Enregistrer";
|
||||
"overview.runtime.goal.title" = "Objectif d'autonomie";
|
||||
"overview.chargetime.goal.title" = "Objectif de recharge";
|
||||
"overview.runtime.placeholder.short" = "Ajouter capacité";
|
||||
"battery.bank.warning.voltage.short" = "Tension";
|
||||
"battery.bank.warning.capacity.short" = "Capacité";
|
||||
|
||||
@@ -293,6 +309,8 @@
|
||||
"chargers.onboarding.subtitle" = "Suivez l'alimentation quai, les boosters et les régulateurs solaires pour connaître votre capacité de charge.";
|
||||
"chargers.onboarding.primary" = "Créer un chargeur";
|
||||
|
||||
"sample.battery.rv.name" = "Batterie de service LiFePO4";
|
||||
"sample.battery.workshop.name" = "Batterie de secours de l'établi";
|
||||
"sample.charger.shore.name" = "Chargeur de quai";
|
||||
"sample.charger.dcdc.name" = "Chargeur DC-DC";
|
||||
"sample.charger.workbench.name" = "Chargeur d'établi";
|
||||
|
||||
@@ -176,6 +176,22 @@
|
||||
"overview.runtime.title" = "Geschatte looptijd";
|
||||
"overview.runtime.subtitle" = "Bij huidige belasting";
|
||||
"overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen.";
|
||||
"overview.bom.title" = "Stuklijst";
|
||||
"overview.bom.subtitle" = "Tik om componenten te bekijken";
|
||||
"overview.bom.unavailable" = "Voeg verbruikers toe om componenten te genereren.";
|
||||
"overview.bom.placeholder.short" = "Lasten toevoegen";
|
||||
"overview.chargetime.title" = "Geschatte laadtijd";
|
||||
"overview.chargetime.subtitle" = "Met gecombineerde laadsnelheid";
|
||||
"overview.chargetime.unavailable" = "Voeg laders en accucapaciteit toe voor een schatting.";
|
||||
"overview.chargetime.placeholder.short" = "Laders toevoegen";
|
||||
"overview.goal.prefix" = "Doel";
|
||||
"overview.goal.label" = "Doel %@";
|
||||
"overview.goal.clear" = "Doel verwijderen";
|
||||
"overview.goal.cancel" = "Annuleren";
|
||||
"overview.goal.save" = "Opslaan";
|
||||
"overview.runtime.goal.title" = "Looptijddoel";
|
||||
"overview.chargetime.goal.title" = "Laadtijddoel";
|
||||
"overview.runtime.placeholder.short" = "Capaciteit toevoegen";
|
||||
"battery.bank.warning.voltage.short" = "Spanning";
|
||||
"battery.bank.warning.capacity.short" = "Capaciteit";
|
||||
|
||||
@@ -293,6 +309,8 @@
|
||||
"chargers.onboarding.subtitle" = "Houd walstroom, boosters en zonne-regelaars bij om je laadcapaciteit te kennen.";
|
||||
"chargers.onboarding.primary" = "Lader aanmaken";
|
||||
|
||||
"sample.battery.rv.name" = "LiFePO4-huishoudaccu";
|
||||
"sample.battery.workshop.name" = "Reserveaccu voor werkbank";
|
||||
"sample.charger.shore.name" = "Walstroomlader";
|
||||
"sample.charger.dcdc.name" = "DC-DC-lader";
|
||||
"sample.charger.workbench.name" = "Werkplaatslader";
|
||||
|
||||
Reference in New Issue
Block a user