Compare commits
2 Commits
3c366dc454
...
4827ea4cdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4827ea4cdb | ||
|
|
28ad6dd10c |
@@ -406,7 +406,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
@@ -441,7 +441,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
|
||||
@@ -67,3 +67,48 @@
|
||||
"system.icon.keywords.heat" = "heat, heater, furnace";
|
||||
"system.icon.keywords.cold" = "cold, freeze, cool";
|
||||
"system.icon.keywords.climate" = "climate, hvac, temperature";
|
||||
|
||||
"tab.components" = "Components";
|
||||
"tab.batteries" = "Batteries";
|
||||
"tab.chargers" = "Chargers";
|
||||
|
||||
"battery.bank.header.title" = "Battery Bank";
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
"battery.bank.metric.capacity" = "Capacity";
|
||||
"battery.bank.metric.energy" = "Energy";
|
||||
"battery.bank.badge.voltage" = "Voltage";
|
||||
"battery.bank.badge.capacity" = "Capacity";
|
||||
"battery.bank.badge.energy" = "Energy";
|
||||
"battery.bank.banner.voltage" = "Voltage mismatch detected";
|
||||
"battery.bank.banner.capacity" = "Capacity mismatch detected";
|
||||
"battery.bank.empty.title" = "No Batteries Yet";
|
||||
"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@.";
|
||||
"battery.bank.status.dismiss" = "Got it";
|
||||
"battery.bank.status.single.battery" = "One battery";
|
||||
"battery.bank.status.multiple.batteries" = "%d batteries";
|
||||
"battery.bank.status.voltage.title" = "Voltage mismatch";
|
||||
"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.";
|
||||
"battery.bank.status.capacity.title" = "Capacity mismatch";
|
||||
"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.";
|
||||
|
||||
"battery.editor.title" = "Battery Setup";
|
||||
"battery.editor.cancel" = "Cancel";
|
||||
"battery.editor.save" = "Save";
|
||||
"battery.editor.field.name" = "Name";
|
||||
"battery.editor.placeholder.name" = "House Bank";
|
||||
"battery.editor.field.chemistry" = "Chemistry";
|
||||
"battery.editor.section.summary" = "Summary";
|
||||
"battery.editor.slider.voltage" = "Nominal Voltage";
|
||||
"battery.editor.slider.capacity" = "Capacity";
|
||||
"battery.editor.alert.voltage.title" = "Edit Nominal Voltage";
|
||||
"battery.editor.alert.voltage.placeholder" = "Voltage";
|
||||
"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)";
|
||||
"battery.editor.alert.capacity.title" = "Edit Capacity";
|
||||
"battery.editor.alert.capacity.placeholder" = "Capacity";
|
||||
"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)";
|
||||
"battery.editor.alert.cancel" = "Cancel";
|
||||
"battery.editor.alert.save" = "Save";
|
||||
"battery.editor.default_name" = "New Battery";
|
||||
|
||||
"chargers.title" = "Chargers for %@";
|
||||
"chargers.subtitle" = "Charger components will be available soon.";
|
||||
|
||||
607
Cable/BatteriesView.swift
Normal file
607
Cable/BatteriesView.swift
Normal file
@@ -0,0 +1,607 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BatteriesView: View {
|
||||
let system: ElectricalSystem
|
||||
let batteries: [SavedBattery]
|
||||
let onEdit: (SavedBattery) -> Void
|
||||
let onDelete: (IndexSet) -> Void
|
||||
|
||||
private enum BankStatus: Identifiable {
|
||||
case voltage(target: Double, mismatchedCount: Int)
|
||||
case capacity(target: Double, mismatchedCount: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .voltage: return "voltage"
|
||||
case .capacity: return "capacity"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .voltage: return "exclamationmark.triangle.fill"
|
||||
case .capacity: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var tint: Color {
|
||||
switch self {
|
||||
case .voltage: return .red
|
||||
case .capacity: return .orange
|
||||
}
|
||||
}
|
||||
|
||||
var bannerText: String {
|
||||
switch self {
|
||||
case .voltage:
|
||||
return String(
|
||||
localized: "battery.bank.banner.voltage",
|
||||
bundle: .main,
|
||||
comment: "Short banner text describing a voltage mismatch"
|
||||
)
|
||||
case .capacity:
|
||||
return String(
|
||||
localized: "battery.bank.banner.capacity",
|
||||
bundle: .main,
|
||||
comment: "Short banner text describing a capacity mismatch"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let voltageTolerance: Double = 0.05
|
||||
private let capacityTolerance: Double = 0.5
|
||||
@State private var activeStatus: BankStatus?
|
||||
|
||||
private var bankTitle: String {
|
||||
String(
|
||||
localized: "battery.bank.header.title",
|
||||
bundle: .main,
|
||||
comment: "Title for the battery bank summary section"
|
||||
)
|
||||
}
|
||||
|
||||
private var metricCountLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.metric.count",
|
||||
bundle: .main,
|
||||
comment: "Label for number of batteries metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var metricCapacityLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.metric.capacity",
|
||||
bundle: .main,
|
||||
comment: "Label for total capacity metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var metricEnergyLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.metric.energy",
|
||||
bundle: .main,
|
||||
comment: "Label for total energy metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgeVoltageLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.badge.voltage",
|
||||
bundle: .main,
|
||||
comment: "Label for voltage badge"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgeCapacityLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.badge.capacity",
|
||||
bundle: .main,
|
||||
comment: "Label for capacity badge"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgeEnergyLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.badge.energy",
|
||||
bundle: .main,
|
||||
comment: "Label for energy badge"
|
||||
)
|
||||
}
|
||||
|
||||
private var emptyTitle: String {
|
||||
String(
|
||||
localized: "battery.bank.empty.title",
|
||||
bundle: .main,
|
||||
comment: "Title shown when no batteries are configured"
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
system: ElectricalSystem,
|
||||
batteries: [SavedBattery],
|
||||
onEdit: @escaping (SavedBattery) -> Void = { _ in },
|
||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
||||
) {
|
||||
self.system = system
|
||||
self.batteries = batteries
|
||||
self.onEdit = onEdit
|
||||
self.onDelete = onDelete
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if batteries.isEmpty {
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
summarySection
|
||||
|
||||
List {
|
||||
ForEach(batteries) { battery in
|
||||
Button {
|
||||
onEdit(battery)
|
||||
} label: {
|
||||
batteryRow(for: battery)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onDelete(perform: onDelete)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.alert(item: $activeStatus) { status in
|
||||
let detail = detailInfo(for: status)
|
||||
return Alert(
|
||||
title: Text(detail.title),
|
||||
message: Text(detail.message),
|
||||
dismissButton: .default(
|
||||
Text(
|
||||
NSLocalizedString(
|
||||
"battery.bank.status.dismiss",
|
||||
bundle: .main,
|
||||
value: "Got it",
|
||||
comment: "Dismiss button title for battery bank status alert"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(bankTitle)
|
||||
.font(.headline.weight(.semibold))
|
||||
Spacer()
|
||||
Text(system.name)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: metricCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .blue
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "gauge.medium",
|
||||
label: metricCapacityLabel,
|
||||
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||
tint: .orange
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "bolt.circle",
|
||||
label: metricEnergyLabel,
|
||||
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||
tint: .green
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: metricCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .blue
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "gauge.medium",
|
||||
label: metricCapacityLabel,
|
||||
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||
tint: .orange
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "bolt.circle",
|
||||
label: metricEnergyLabel,
|
||||
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||
tint: .green
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if let status = bankStatus {
|
||||
Button {
|
||||
activeStatus = status
|
||||
} label: {
|
||||
statusBanner(for: status)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
|
||||
Divider()
|
||||
.background(Color(.separator))
|
||||
}
|
||||
}
|
||||
|
||||
private func batteryRow(for battery: SavedBattery) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
batteryIcon
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(battery.name)
|
||||
.font(.body.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Spacer()
|
||||
Text(formattedValue(battery.nominalVoltage, unit: "V"))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(battery.chemistry.displayName)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
metricBadge(
|
||||
label: badgeVoltageLabel,
|
||||
value: formattedValue(battery.nominalVoltage, unit: "V"),
|
||||
tint: .orange
|
||||
)
|
||||
metricBadge(
|
||||
label: badgeCapacityLabel,
|
||||
value: formattedValue(battery.capacityAmpHours, unit: "Ah"),
|
||||
tint: .blue
|
||||
)
|
||||
metricBadge(
|
||||
label: badgeEnergyLabel,
|
||||
value: formattedValue(battery.energyWattHours, unit: "Wh"),
|
||||
tint: .green
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var batteryIcon: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(colorForName(system.colorName))
|
||||
.frame(width: 48, height: 48)
|
||||
Image(systemName: "battery.100.bolt")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
}
|
||||
|
||||
private var totalCapacity: Double {
|
||||
batteries.reduce(0) { result, battery in
|
||||
result + battery.capacityAmpHours
|
||||
}
|
||||
}
|
||||
|
||||
private var totalEnergy: Double {
|
||||
batteries.reduce(0) { result, battery in
|
||||
result + battery.energyWattHours
|
||||
}
|
||||
}
|
||||
|
||||
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(value)
|
||||
.font(.body.weight(.semibold))
|
||||
}
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
|
||||
private func statusBanner(for status: BankStatus) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: status.symbol)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(status.tint)
|
||||
Text(status.bannerText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(status.tint)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(status.tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
|
||||
private func colorForName(_ colorName: String) -> Color {
|
||||
switch colorName {
|
||||
case "blue": return .blue
|
||||
case "green": return .green
|
||||
case "orange": return .orange
|
||||
case "red": return .red
|
||||
case "purple": return .purple
|
||||
case "yellow": return .yellow
|
||||
case "pink": return .pink
|
||||
case "teal": return .teal
|
||||
case "indigo": return .indigo
|
||||
case "mint": return .mint
|
||||
case "cyan": return .cyan
|
||||
case "brown": return .brown
|
||||
case "gray": return .gray
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "battery.100")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(emptyTitle)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(emptySubtitle(for: system.name))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private static let numberFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 1
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func formattedValue(_ value: Double, unit: String) -> String {
|
||||
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||
return "\(numberString) \(unit)"
|
||||
}
|
||||
|
||||
private var dominantVoltage: Double? {
|
||||
guard batteries.count > 1 else { return nil }
|
||||
return dominantValue(
|
||||
from: batteries.map { $0.nominalVoltage },
|
||||
scale: 0.1
|
||||
)
|
||||
}
|
||||
|
||||
private var dominantCapacity: Double? {
|
||||
guard batteries.count > 1 else { return nil }
|
||||
return dominantValue(
|
||||
from: batteries.map { $0.capacityAmpHours },
|
||||
scale: 1.0
|
||||
)
|
||||
}
|
||||
|
||||
private func dominantValue(from values: [Double], scale: Double) -> Double? {
|
||||
guard !values.isEmpty else { return nil }
|
||||
var counts: [Double: Int] = [:]
|
||||
var bestKey: Double?
|
||||
var bestCount = 0
|
||||
for value in values {
|
||||
let key = (value / scale).rounded() * scale
|
||||
let newCount = (counts[key] ?? 0) + 1
|
||||
counts[key] = newCount
|
||||
if newCount > bestCount {
|
||||
bestCount = newCount
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
return bestKey
|
||||
}
|
||||
|
||||
private var bankStatus: BankStatus? {
|
||||
guard batteries.count > 1 else { return nil }
|
||||
|
||||
if let targetVoltage = dominantVoltage {
|
||||
let mismatched = batteries.filter { abs($0.nominalVoltage - targetVoltage) > voltageTolerance }
|
||||
if !mismatched.isEmpty {
|
||||
return .voltage(target: targetVoltage, mismatchedCount: mismatched.count)
|
||||
}
|
||||
}
|
||||
|
||||
if let targetCapacity = dominantCapacity {
|
||||
let mismatched = batteries.filter { abs($0.capacityAmpHours - targetCapacity) > capacityTolerance }
|
||||
if !mismatched.isEmpty {
|
||||
return .capacity(target: targetCapacity, mismatchedCount: mismatched.count)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func emptySubtitle(for systemName: String) -> String {
|
||||
let format = NSLocalizedString(
|
||||
"battery.bank.empty.subtitle",
|
||||
tableName: nil,
|
||||
bundle: .main,
|
||||
value: "Tap the plus button to configure a battery for %@.",
|
||||
comment: "Subtitle shown when no batteries are configured"
|
||||
)
|
||||
return String(format: format, systemName)
|
||||
}
|
||||
|
||||
private func detailInfo(for status: BankStatus) -> (title: String, message: String) {
|
||||
switch status {
|
||||
case let .voltage(target, mismatchedCount):
|
||||
let countText = mismatchedCount == 1
|
||||
? NSLocalizedString(
|
||||
"battery.bank.status.single.battery",
|
||||
bundle: .main,
|
||||
value: "One battery",
|
||||
comment: "Singular form describing mismatched battery count"
|
||||
)
|
||||
: String(
|
||||
format: NSLocalizedString(
|
||||
"battery.bank.status.multiple.batteries",
|
||||
bundle: .main,
|
||||
value: "%d batteries",
|
||||
comment: "Plural form describing mismatched battery count"
|
||||
),
|
||||
mismatchedCount
|
||||
)
|
||||
let expected = formattedValue(target, unit: "V")
|
||||
let message = String(
|
||||
format: NSLocalizedString(
|
||||
"battery.bank.status.voltage.message",
|
||||
bundle: .main,
|
||||
value: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.",
|
||||
comment: "Explanation for voltage mismatch in battery bank"
|
||||
),
|
||||
countText,
|
||||
expected
|
||||
)
|
||||
return (
|
||||
NSLocalizedString(
|
||||
"battery.bank.status.voltage.title",
|
||||
bundle: .main,
|
||||
value: "Voltage mismatch",
|
||||
comment: "Title for voltage mismatch alert"
|
||||
),
|
||||
message
|
||||
)
|
||||
case let .capacity(target, mismatchedCount):
|
||||
let countText = mismatchedCount == 1
|
||||
? NSLocalizedString(
|
||||
"battery.bank.status.single.battery",
|
||||
bundle: .main,
|
||||
value: "One battery",
|
||||
comment: "Singular form describing mismatched battery count"
|
||||
)
|
||||
: String(
|
||||
format: NSLocalizedString(
|
||||
"battery.bank.status.multiple.batteries",
|
||||
bundle: .main,
|
||||
value: "%d batteries",
|
||||
comment: "Plural form describing mismatched battery count"
|
||||
),
|
||||
mismatchedCount
|
||||
)
|
||||
let expected = formattedValue(target, unit: "Ah")
|
||||
let message = String(
|
||||
format: NSLocalizedString(
|
||||
"battery.bank.status.capacity.message",
|
||||
bundle: .main,
|
||||
value: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.",
|
||||
comment: "Explanation for capacity mismatch in battery bank"
|
||||
),
|
||||
countText,
|
||||
expected
|
||||
)
|
||||
return (
|
||||
NSLocalizedString(
|
||||
"battery.bank.status.capacity.title",
|
||||
bundle: .main,
|
||||
value: "Capacity mismatch",
|
||||
comment: "Title for capacity mismatch alert"
|
||||
),
|
||||
message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum BatteriesViewPreviewData {
|
||||
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "green")
|
||||
static let batteries: [SavedBattery] = [
|
||||
SavedBattery(
|
||||
name: "House Bank",
|
||||
nominalVoltage: 12.8,
|
||||
capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
system: system
|
||||
),
|
||||
SavedBattery(
|
||||
name: "Starter Battery",
|
||||
nominalVoltage: 12.0,
|
||||
capacityAmpHours: 90,
|
||||
chemistry: .agm,
|
||||
system: system
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
#Preview {
|
||||
BatteriesView(
|
||||
system: BatteriesViewPreviewData.system,
|
||||
batteries: BatteriesViewPreviewData.batteries
|
||||
)
|
||||
}
|
||||
63
Cable/BatteryConfiguration.swift
Normal file
63
Cable/BatteryConfiguration.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct BatteryConfiguration: Identifiable {
|
||||
enum Chemistry: String, CaseIterable, Identifiable {
|
||||
case agm = "AGM"
|
||||
case gel = "Gel"
|
||||
case floodedLeadAcid = "Flooded Lead Acid"
|
||||
case lithiumIronPhosphate = "LiFePO4"
|
||||
case lithiumIon = "Lithium Ion"
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var displayName: String {
|
||||
rawValue
|
||||
}
|
||||
}
|
||||
|
||||
let id: UUID
|
||||
var name: String
|
||||
var nominalVoltage: Double
|
||||
var capacityAmpHours: Double
|
||||
var chemistry: Chemistry
|
||||
var system: ElectricalSystem
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
nominalVoltage: Double = 12.8,
|
||||
capacityAmpHours: Double = 100,
|
||||
chemistry: Chemistry = .lithiumIronPhosphate,
|
||||
system: ElectricalSystem
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.nominalVoltage = nominalVoltage
|
||||
self.capacityAmpHours = capacityAmpHours
|
||||
self.chemistry = chemistry
|
||||
self.system = system
|
||||
}
|
||||
|
||||
init(savedBattery: SavedBattery, system: ElectricalSystem) {
|
||||
self.id = savedBattery.id
|
||||
self.name = savedBattery.name
|
||||
self.nominalVoltage = savedBattery.nominalVoltage
|
||||
self.capacityAmpHours = savedBattery.capacityAmpHours
|
||||
self.chemistry = savedBattery.chemistry
|
||||
self.system = system
|
||||
}
|
||||
|
||||
var energyWattHours: Double {
|
||||
nominalVoltage * capacityAmpHours
|
||||
}
|
||||
|
||||
func apply(to savedBattery: SavedBattery) {
|
||||
savedBattery.name = name
|
||||
savedBattery.nominalVoltage = nominalVoltage
|
||||
savedBattery.capacityAmpHours = capacityAmpHours
|
||||
savedBattery.chemistry = chemistry
|
||||
savedBattery.system = system
|
||||
savedBattery.timestamp = Date()
|
||||
}
|
||||
}
|
||||
478
Cable/BatteryEditorView.swift
Normal file
478
Cable/BatteryEditorView.swift
Normal file
@@ -0,0 +1,478 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BatteryEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var configuration: BatteryConfiguration
|
||||
@State private var editingField: EditingField?
|
||||
|
||||
let onSave: (BatteryConfiguration) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
private enum EditingField {
|
||||
case voltage
|
||||
case capacity
|
||||
}
|
||||
|
||||
private let voltageSnapValues: [Double] = [6, 12, 12.8, 24, 25.6, 36, 48, 51.2]
|
||||
private let capacitySnapValues: [Double] = [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000]
|
||||
private let voltageSnapTolerance: Double = 0.5
|
||||
private let capacitySnapTolerance: Double = 10.0
|
||||
|
||||
private var nameFieldLabel: String {
|
||||
String(
|
||||
localized: "battery.editor.field.name",
|
||||
bundle: .main,
|
||||
comment: "Label for the battery name text field"
|
||||
)
|
||||
}
|
||||
|
||||
private var namePlaceholder: String {
|
||||
String(
|
||||
localized: "battery.editor.placeholder.name",
|
||||
bundle: .main,
|
||||
comment: "Placeholder example for the battery name field"
|
||||
)
|
||||
}
|
||||
|
||||
private var chemistryLabel: String {
|
||||
String(
|
||||
localized: "battery.editor.field.chemistry",
|
||||
bundle: .main,
|
||||
comment: "Label describing the chemistry menu"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryLabel: String {
|
||||
String(
|
||||
localized: "battery.editor.section.summary",
|
||||
bundle: .main,
|
||||
comment: "Label for the summary section in the editor"
|
||||
)
|
||||
}
|
||||
|
||||
private var sliderVoltageTitle: String {
|
||||
String(
|
||||
localized: "battery.editor.slider.voltage",
|
||||
bundle: .main,
|
||||
comment: "Title for the nominal voltage slider"
|
||||
)
|
||||
}
|
||||
|
||||
private var sliderCapacityTitle: String {
|
||||
String(
|
||||
localized: "battery.editor.slider.capacity",
|
||||
bundle: .main,
|
||||
comment: "Title for the capacity slider"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryVoltageLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.badge.voltage",
|
||||
bundle: .main,
|
||||
comment: "Label used for voltage values"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryCapacityLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.badge.capacity",
|
||||
bundle: .main,
|
||||
comment: "Label used for capacity values"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryEnergyLabel: String {
|
||||
String(
|
||||
localized: "battery.bank.badge.energy",
|
||||
bundle: .main,
|
||||
comment: "Label used for energy values"
|
||||
)
|
||||
}
|
||||
|
||||
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void, onCancel: @escaping () -> Void) {
|
||||
_configuration = State(initialValue: configuration)
|
||||
self.onSave = onSave
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerCard
|
||||
slidersSection
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle(
|
||||
NSLocalizedString(
|
||||
"battery.editor.title",
|
||||
bundle: .main,
|
||||
value: "Battery Setup",
|
||||
comment: "Title for the battery editor"
|
||||
)
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.cancel",
|
||||
bundle: .main,
|
||||
value: "Cancel",
|
||||
comment: "Cancel button title"
|
||||
)
|
||||
) {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.save",
|
||||
bundle: .main,
|
||||
value: "Save",
|
||||
comment: "Save button title"
|
||||
)
|
||||
) {
|
||||
save()
|
||||
}
|
||||
.disabled(configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.voltage.title",
|
||||
bundle: .main,
|
||||
value: "Edit Nominal Voltage",
|
||||
comment: "Title for the voltage edit alert"
|
||||
),
|
||||
isPresented: Binding(
|
||||
get: { editingField == .voltage },
|
||||
set: { if !$0 { editingField = nil } }
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.voltage.placeholder",
|
||||
bundle: .main,
|
||||
value: "Voltage",
|
||||
comment: "Placeholder for voltage text field"
|
||||
),
|
||||
value: $configuration.nominalVoltage,
|
||||
format: .number
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.cancel",
|
||||
bundle: .main,
|
||||
value: "Cancel",
|
||||
comment: "Cancel button title for edit alerts"
|
||||
),
|
||||
role: .cancel
|
||||
) { editingField = nil }
|
||||
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.save",
|
||||
bundle: .main,
|
||||
value: "Save",
|
||||
comment: "Save button title for edit alerts"
|
||||
)
|
||||
) {
|
||||
editingField = nil
|
||||
let normalized = normalizedVoltage(for: configuration.nominalVoltage)
|
||||
if abs(normalized - configuration.nominalVoltage) > 0.000001 {
|
||||
configuration.nominalVoltage = normalized
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.voltage.message",
|
||||
bundle: .main,
|
||||
value: "Enter voltage in volts (V)",
|
||||
comment: "Message for the voltage edit alert"
|
||||
)
|
||||
)
|
||||
}
|
||||
.alert(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.capacity.title",
|
||||
bundle: .main,
|
||||
value: "Edit Capacity",
|
||||
comment: "Title for the capacity edit alert"
|
||||
),
|
||||
isPresented: Binding(
|
||||
get: { editingField == .capacity },
|
||||
set: { if !$0 { editingField = nil } }
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.capacity.placeholder",
|
||||
bundle: .main,
|
||||
value: "Capacity",
|
||||
comment: "Placeholder for capacity text field"
|
||||
),
|
||||
value: $configuration.capacityAmpHours,
|
||||
format: .number
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.cancel",
|
||||
bundle: .main,
|
||||
value: "Cancel",
|
||||
comment: "Cancel button title for edit alerts"
|
||||
),
|
||||
role: .cancel
|
||||
) { editingField = nil }
|
||||
|
||||
Button(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.save",
|
||||
bundle: .main,
|
||||
value: "Save",
|
||||
comment: "Save button title for edit alerts"
|
||||
)
|
||||
) {
|
||||
editingField = nil
|
||||
let normalized = normalizedCapacity(for: configuration.capacityAmpHours)
|
||||
if abs(normalized - configuration.capacityAmpHours) > 0.000001 {
|
||||
configuration.capacityAmpHours = normalized
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.capacity.message",
|
||||
bundle: .main,
|
||||
value: "Enter capacity in amp-hours (Ah)",
|
||||
comment: "Message for the capacity edit alert"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(nameFieldLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(namePlaceholder, text: $configuration.name)
|
||||
.textInputAutocapitalization(.words)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(chemistryLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in
|
||||
Button {
|
||||
configuration.chemistry = chemistry
|
||||
} label: {
|
||||
if chemistry == configuration.chemistry {
|
||||
Label(chemistry.displayName, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(chemistry.displayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(configuration.chemistry.displayName)
|
||||
.font(.body.weight(.semibold))
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.footnote.weight(.bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(summaryLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
summaryBadge(
|
||||
title: summaryVoltageLabel,
|
||||
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
||||
symbol: "bolt"
|
||||
)
|
||||
summaryBadge(
|
||||
title: summaryCapacityLabel,
|
||||
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
||||
symbol: "gauge.medium"
|
||||
)
|
||||
summaryBadge(
|
||||
title: summaryEnergyLabel,
|
||||
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
||||
symbol: "battery.100.bolt"
|
||||
)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
summaryBadge(
|
||||
title: summaryVoltageLabel,
|
||||
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
||||
symbol: "bolt"
|
||||
)
|
||||
summaryBadge(
|
||||
title: summaryCapacityLabel,
|
||||
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
||||
symbol: "gauge.medium"
|
||||
)
|
||||
summaryBadge(
|
||||
title: summaryEnergyLabel,
|
||||
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
||||
symbol: "battery.100.bolt"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var slidersSection: some View {
|
||||
VStack(spacing: 30) {
|
||||
SliderSection(
|
||||
title: sliderVoltageTitle,
|
||||
value: $configuration.nominalVoltage,
|
||||
range: 6...60,
|
||||
unit: "V",
|
||||
tapAction: { editingField = .voltage },
|
||||
snapValues: voltageSnapValues
|
||||
)
|
||||
.onChange(of: configuration.nominalVoltage) { _, newValue in
|
||||
let normalized = normalizedVoltage(for: newValue)
|
||||
if abs(normalized - newValue) > 0.000001 {
|
||||
configuration.nominalVoltage = normalized
|
||||
}
|
||||
}
|
||||
|
||||
SliderSection(
|
||||
title: sliderCapacityTitle,
|
||||
value: $configuration.capacityAmpHours,
|
||||
range: 5...1000,
|
||||
unit: "Ah",
|
||||
tapAction: { editingField = .capacity },
|
||||
snapValues: capacitySnapValues
|
||||
)
|
||||
.onChange(of: configuration.capacityAmpHours) { _, newValue in
|
||||
let normalized = normalizedCapacity(for: newValue)
|
||||
if abs(normalized - newValue) > 0.000001 {
|
||||
configuration.capacityAmpHours = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedVoltage(for value: Double) -> Double {
|
||||
let rounded = (value * 10).rounded() / 10
|
||||
if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) {
|
||||
return snapped
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
private func normalizedCapacity(for value: Double) -> Double {
|
||||
let rounded = (value * 10).rounded() / 10
|
||||
if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) {
|
||||
return snapped
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? {
|
||||
guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
|
||||
return abs(closest - value) <= tolerance ? closest : nil
|
||||
}
|
||||
|
||||
private func summaryBadge(title: String, value: String, symbol: String) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: symbol)
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private static let numberFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 1
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func formattedValue(_ value: Double, unit: String) -> String {
|
||||
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||
return "\(numberString) \(unit)"
|
||||
}
|
||||
|
||||
private func save() {
|
||||
onSave(configuration)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func cancel() {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let previewSystem = ElectricalSystem(name: "Camper")
|
||||
return NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
|
||||
onSave: { _ in },
|
||||
onCancel: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ struct CableApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
do {
|
||||
// Try the simple approach first
|
||||
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, Item.self)
|
||||
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self)
|
||||
} catch {
|
||||
print("Failed to create ModelContainer with simple approach: \(error)")
|
||||
|
||||
// Try in-memory as fallback
|
||||
do {
|
||||
let schema = Schema([ElectricalSystem.self, SavedLoad.self, Item.self])
|
||||
let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
|
||||
45
Cable/ChargersView.swift
Normal file
45
Cable/ChargersView.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChargersView: View {
|
||||
let system: ElectricalSystem
|
||||
|
||||
private var titleText: String {
|
||||
let format = NSLocalizedString(
|
||||
"chargers.title",
|
||||
bundle: .main,
|
||||
comment: "Title describing chargers belonging to a system"
|
||||
)
|
||||
return String(format: format, system.name)
|
||||
}
|
||||
|
||||
private var subtitleText: String {
|
||||
String(
|
||||
localized: "chargers.subtitle",
|
||||
bundle: .main,
|
||||
comment: "Subtitle shown while chargers tab is under construction"
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(titleText)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(subtitleText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChargersView(system: ElectricalSystem(name: "Preview System"))
|
||||
}
|
||||
@@ -13,12 +13,15 @@ struct LoadsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
|
||||
@Query(sort: \SavedBattery.timestamp, order: .reverse) private var allBatteries: [SavedBattery]
|
||||
@State private var newLoadToEdit: SavedLoad?
|
||||
@State private var showingSystemEditor = false
|
||||
@State private var hasPresentedSystemEditorOnAppear = false
|
||||
@State private var hasOpenedLoadOnAppear = false
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSystemBOM = false
|
||||
@State private var selectedComponentTab: ComponentTab = .components
|
||||
@State private var batteryDraft: BatteryConfiguration?
|
||||
|
||||
let system: ElectricalSystem
|
||||
private let presentSystemEditorOnAppear: Bool
|
||||
@@ -33,93 +36,59 @@ struct LoadsView: View {
|
||||
private var savedLoads: [SavedLoad] {
|
||||
allLoads.filter { $0.system == system }
|
||||
}
|
||||
|
||||
private var savedBatteries: [SavedBattery] {
|
||||
allBatteries.filter { $0.system == system }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if savedLoads.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
librarySection
|
||||
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
NavigationLink(destination: CalculatorView(savedLoad: load)) {
|
||||
HStack(spacing: 12) {
|
||||
LoadIconView(
|
||||
remoteIconURLString: load.remoteIconURLString,
|
||||
fallbackSystemName: load.iconName,
|
||||
fallbackColor: colorForName(load.colorName),
|
||||
size: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(load.name)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
// Secondary info
|
||||
HStack {
|
||||
Group {
|
||||
Text(String(format: "%.1fV", load.voltage))
|
||||
Text("•")
|
||||
if load.isWattMode {
|
||||
Text(String(format: "%.0fW", load.power))
|
||||
} else {
|
||||
Text(String(format: "%.1fA", load.current))
|
||||
}
|
||||
Text("•")
|
||||
Text(String(format: "%.1f%@",
|
||||
unitSettings.unitSystem == .metric ? load.length : load.length * 3.28084,
|
||||
unitSettings.unitSystem.lengthUnit))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Prominent fuse and wire gauge display
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Text("FUSE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(recommendedFuse(for: load))A")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("WIRE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: unitSettings.unitSystem == .imperial ? "%.0f AWG" : "%.1fmm²",
|
||||
unitSettings.unitSystem == .imperial ? awgFromCrossSection(load.crossSection) : load.crossSection))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
TabView(selection: $selectedComponentTab) {
|
||||
componentsTab
|
||||
.tag(ComponentTab.components)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.components",
|
||||
bundle: .main,
|
||||
comment: "Tab title for components list"
|
||||
),
|
||||
systemImage: "square.stack.3d.up"
|
||||
)
|
||||
}
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
.tag(ComponentTab.batteries)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.batteries",
|
||||
bundle: .main,
|
||||
comment: "Tab title for battery configurations"
|
||||
),
|
||||
systemImage: "battery.100"
|
||||
)
|
||||
}
|
||||
ChargersView(system: system)
|
||||
.tag(ComponentTab.chargers)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.chargers",
|
||||
bundle: .main,
|
||||
comment: "Tab title for chargers view"
|
||||
),
|
||||
systemImage: "bolt.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteLoads)
|
||||
}
|
||||
.accessibilityIdentifier("loads-list")
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -149,7 +118,7 @@ struct LoadsView: View {
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
if !savedLoads.isEmpty {
|
||||
if !savedLoads.isEmpty && selectedComponentTab == .components {
|
||||
Button(action: {
|
||||
showingSystemBOM = true
|
||||
}) {
|
||||
@@ -158,17 +127,34 @@ struct LoadsView: View {
|
||||
.accessibilityIdentifier("system-bom-button")
|
||||
}
|
||||
Button(action: {
|
||||
createNewLoad()
|
||||
handlePrimaryAction()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
EditButton()
|
||||
.disabled(selectedComponentTab == .chargers)
|
||||
if selectedComponentTab == .components || selectedComponentTab == .batteries {
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $newLoadToEdit) { load in
|
||||
CalculatorView(savedLoad: load)
|
||||
}
|
||||
.sheet(item: $batteryDraft) { draft in
|
||||
NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: draft,
|
||||
onSave: { configuration in
|
||||
saveBattery(configuration)
|
||||
batteryDraft = nil
|
||||
},
|
||||
onCancel: {
|
||||
batteryDraft = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
addComponent(item)
|
||||
@@ -253,7 +239,166 @@ struct LoadsView: View {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var componentsTab: some View {
|
||||
VStack(spacing: 0) {
|
||||
librarySection
|
||||
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
Button {
|
||||
selectLoad(load)
|
||||
} label: {
|
||||
loadRow(for: load)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onDelete(perform: deleteLoads)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.accessibilityIdentifier("loads-list")
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private func selectLoad(_ load: SavedLoad) {
|
||||
newLoadToEdit = load
|
||||
}
|
||||
|
||||
private func loadRow(for load: SavedLoad) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
LoadIconView(
|
||||
remoteIconURLString: load.remoteIconURLString,
|
||||
fallbackSystemName: load.iconName,
|
||||
fallbackColor: colorForName(load.colorName),
|
||||
size: 48
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(load.name)
|
||||
.font(.body.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(loadSummary(for: load))
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 12) {
|
||||
metricBadge(
|
||||
label: "Fuse",
|
||||
value: "\(recommendedFuse(for: load)) A",
|
||||
tint: .pink
|
||||
)
|
||||
metricBadge(
|
||||
label: "Cable",
|
||||
value: wireGaugeString(for: load),
|
||||
tint: .teal
|
||||
)
|
||||
metricBadge(
|
||||
label: "Length",
|
||||
value: lengthString(for: load),
|
||||
tint: .orange
|
||||
)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
metricBadge(
|
||||
label: "Fuse",
|
||||
value: "\(recommendedFuse(for: load)) A",
|
||||
tint: .pink
|
||||
)
|
||||
metricBadge(
|
||||
label: "Cable",
|
||||
value: wireGaugeString(for: load),
|
||||
tint: .teal
|
||||
)
|
||||
metricBadge(
|
||||
label: "Length",
|
||||
value: lengthString(for: load),
|
||||
tint: .orange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private func loadSummary(for load: SavedLoad) -> String {
|
||||
let voltageText = String(format: "%.1fV", load.voltage)
|
||||
let lengthText: String
|
||||
if unitSettings.unitSystem == .metric {
|
||||
lengthText = String(format: "%.1f%@", load.length, unitSettings.unitSystem.lengthUnit)
|
||||
} else {
|
||||
let imperialLength = load.length * 3.28084
|
||||
lengthText = String(format: "%.1f%@", imperialLength, unitSettings.unitSystem.lengthUnit)
|
||||
}
|
||||
let powerOrCurrent = load.isWattMode
|
||||
? String(format: "%.0fW", load.power)
|
||||
: String(format: "%.1fA", load.current)
|
||||
return [voltageText, powerOrCurrent, lengthText].joined(separator: " • ")
|
||||
}
|
||||
|
||||
private func wireGaugeString(for load: SavedLoad) -> String {
|
||||
if unitSettings.unitSystem == .imperial {
|
||||
let awgValue = awgFromCrossSection(load.crossSection)
|
||||
return String(format: "%.0f AWG", awgValue)
|
||||
} else {
|
||||
return String(format: "%.1f mm²", load.crossSection)
|
||||
}
|
||||
}
|
||||
|
||||
private func lengthString(for load: SavedLoad) -> String {
|
||||
if unitSettings.unitSystem == .imperial {
|
||||
let imperialLength = load.length * 3.28084
|
||||
return String(format: "%.1f ft", imperialLength)
|
||||
} else {
|
||||
return String(format: "%.1f m", load.length)
|
||||
}
|
||||
}
|
||||
|
||||
private func metricBadge(label: String, value: String, tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
ComponentsOnboardingView(
|
||||
onCreate: { createNewLoad() },
|
||||
@@ -268,83 +413,69 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePrimaryAction() {
|
||||
switch selectedComponentTab {
|
||||
case .components:
|
||||
createNewLoad()
|
||||
case .batteries:
|
||||
startBatteryConfiguration()
|
||||
case .chargers:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewLoad() {
|
||||
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
|
||||
let loadName = uniqueLoadName(startingWith: defaultName)
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: 12.0,
|
||||
current: 5.0,
|
||||
power: 60.0, // 12V * 5A = 60W
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: false,
|
||||
system: system,
|
||||
remoteIconURLString: nil
|
||||
let newLoad = SystemComponentsPersistence.createDefaultLoad(
|
||||
for: system,
|
||||
in: modelContext,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries
|
||||
)
|
||||
modelContext.insert(newLoad)
|
||||
|
||||
// Navigate to the new load
|
||||
newLoadToEdit = newLoad
|
||||
}
|
||||
|
||||
private func startBatteryConfiguration() {
|
||||
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
||||
for: system,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries
|
||||
)
|
||||
}
|
||||
|
||||
private func saveBattery(_ configuration: BatteryConfiguration) {
|
||||
SystemComponentsPersistence.saveBattery(
|
||||
configuration,
|
||||
for: system,
|
||||
existingBatteries: savedBatteries,
|
||||
in: modelContext
|
||||
)
|
||||
}
|
||||
|
||||
private func editBattery(_ battery: SavedBattery) {
|
||||
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
|
||||
}
|
||||
|
||||
private func deleteBatteries(_ offsets: IndexSet) {
|
||||
withAnimation {
|
||||
SystemComponentsPersistence.deleteBatteries(
|
||||
at: offsets,
|
||||
from: savedBatteries,
|
||||
in: modelContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addComponent(_ item: ComponentLibraryItem) {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
|
||||
let loadName = uniqueLoadName(startingWith: baseName)
|
||||
let voltage = item.displayVoltage ?? 12.0
|
||||
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
||||
let current: Double
|
||||
if let explicitCurrent = item.current {
|
||||
current = explicitCurrent
|
||||
} else if voltage > 0 {
|
||||
current = power / voltage
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: power,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: item.watt != nil,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
let newLoad = SystemComponentsPersistence.createLoad(
|
||||
from: item,
|
||||
for: system,
|
||||
in: modelContext,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries
|
||||
)
|
||||
|
||||
modelContext.insert(newLoad)
|
||||
newLoadToEdit = newLoad
|
||||
}
|
||||
|
||||
private func uniqueLoadName(startingWith baseName: String) -> String {
|
||||
let existingNames = Set(savedLoads.map { $0.name })
|
||||
|
||||
if !existingNames.contains(baseName) {
|
||||
return baseName
|
||||
}
|
||||
|
||||
var counter = 2
|
||||
var candidate = "\(baseName) \(counter)"
|
||||
|
||||
while existingNames.contains(candidate) {
|
||||
counter += 1
|
||||
candidate = "\(baseName) \(counter)"
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func colorForName(_ colorName: String) -> Color {
|
||||
switch colorName {
|
||||
@@ -384,4 +515,10 @@ struct LoadsView: View {
|
||||
// Find the smallest standard fuse that's >= target
|
||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
||||
}
|
||||
|
||||
private enum ComponentTab: Hashable {
|
||||
case components
|
||||
case batteries
|
||||
case chargers
|
||||
}
|
||||
}
|
||||
|
||||
44
Cable/SavedBattery.swift
Normal file
44
Cable/SavedBattery.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
class SavedBattery {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var nominalVoltage: Double
|
||||
var capacityAmpHours: Double
|
||||
private var chemistryRawValue: String
|
||||
var system: ElectricalSystem?
|
||||
var timestamp: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
nominalVoltage: Double = 12.8,
|
||||
capacityAmpHours: Double = 100,
|
||||
chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate,
|
||||
system: ElectricalSystem? = nil,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.nominalVoltage = nominalVoltage
|
||||
self.capacityAmpHours = capacityAmpHours
|
||||
self.chemistryRawValue = chemistry.rawValue
|
||||
self.system = system
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
var chemistry: BatteryConfiguration.Chemistry {
|
||||
get {
|
||||
BatteryConfiguration.Chemistry(rawValue: chemistryRawValue) ?? .lithiumIronPhosphate
|
||||
}
|
||||
set {
|
||||
chemistryRawValue = newValue.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var energyWattHours: Double {
|
||||
nominalVoltage * capacityAmpHours
|
||||
}
|
||||
}
|
||||
159
Cable/SystemComponentsPersistence.swift
Normal file
159
Cable/SystemComponentsPersistence.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SystemComponentsPersistence {
|
||||
static func createDefaultLoad(
|
||||
for system: ElectricalSystem,
|
||||
in context: ModelContext,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery]
|
||||
) -> SavedLoad {
|
||||
let defaultName = String(
|
||||
localized: "default.load.new",
|
||||
comment: "Default name when creating a new load from system view"
|
||||
)
|
||||
let loadName = uniqueName(
|
||||
startingWith: defaultName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries
|
||||
)
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: 12.0,
|
||||
current: 5.0,
|
||||
power: 60.0,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: false,
|
||||
system: system,
|
||||
remoteIconURLString: nil
|
||||
)
|
||||
context.insert(newLoad)
|
||||
return newLoad
|
||||
}
|
||||
|
||||
static func createLoad(
|
||||
from item: ComponentLibraryItem,
|
||||
for system: ElectricalSystem,
|
||||
in context: ModelContext,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery]
|
||||
) -> SavedLoad {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
|
||||
let loadName = uniqueName(
|
||||
startingWith: baseName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries
|
||||
)
|
||||
let voltage = item.displayVoltage ?? 12.0
|
||||
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
||||
let current: Double
|
||||
if let explicitCurrent = item.current {
|
||||
current = explicitCurrent
|
||||
} else if voltage > 0 {
|
||||
current = power / voltage
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: power,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: item.watt != nil,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
)
|
||||
|
||||
context.insert(newLoad)
|
||||
return newLoad
|
||||
}
|
||||
|
||||
static func makeBatteryDraft(
|
||||
for system: ElectricalSystem,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery]
|
||||
) -> BatteryConfiguration {
|
||||
let defaultName = NSLocalizedString(
|
||||
"battery.editor.default_name",
|
||||
bundle: .main,
|
||||
value: "New Battery",
|
||||
comment: "Default name when configuring a new battery"
|
||||
)
|
||||
let batteryName = uniqueName(
|
||||
startingWith: defaultName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries
|
||||
)
|
||||
return BatteryConfiguration(
|
||||
name: batteryName,
|
||||
system: system
|
||||
)
|
||||
}
|
||||
|
||||
static func saveBattery(
|
||||
_ configuration: BatteryConfiguration,
|
||||
for system: ElectricalSystem,
|
||||
existingBatteries: [SavedBattery],
|
||||
in context: ModelContext
|
||||
) {
|
||||
if let existing = existingBatteries.first(where: { $0.id == configuration.id }) {
|
||||
configuration.apply(to: existing)
|
||||
} else {
|
||||
let newBattery = SavedBattery(
|
||||
id: configuration.id,
|
||||
name: configuration.name,
|
||||
nominalVoltage: configuration.nominalVoltage,
|
||||
capacityAmpHours: configuration.capacityAmpHours,
|
||||
chemistry: configuration.chemistry,
|
||||
system: system
|
||||
)
|
||||
context.insert(newBattery)
|
||||
}
|
||||
}
|
||||
|
||||
static func deleteBatteries(
|
||||
at offsets: IndexSet,
|
||||
from batteries: [SavedBattery],
|
||||
in context: ModelContext
|
||||
) {
|
||||
for index in offsets {
|
||||
context.delete(batteries[index])
|
||||
}
|
||||
}
|
||||
|
||||
static func uniqueName(
|
||||
startingWith baseName: String,
|
||||
loads: [SavedLoad],
|
||||
batteries: [SavedBattery]
|
||||
) -> String {
|
||||
let existingNames = Set(loads.map { $0.name } + batteries.map { $0.name })
|
||||
|
||||
if !existingNames.contains(baseName) {
|
||||
return baseName
|
||||
}
|
||||
|
||||
var counter = 2
|
||||
var candidate = "\(baseName) \(counter)"
|
||||
|
||||
while existingNames.contains(candidate) {
|
||||
counter += 1
|
||||
candidate = "\(baseName) \(counter)"
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
"slider.length.title" = "Kabellänge (%@)";
|
||||
"slider.power.title" = "Leistung";
|
||||
"slider.voltage.title" = "Spannung";
|
||||
"system.list.no.components" = "Noch keine Komponenten";
|
||||
"system.list.no.components" = "Noch keine Verbraucher";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Metrisch (mm², m)";
|
||||
"sample.system.rv.name" = "Abenteuer-Van";
|
||||
@@ -78,14 +78,14 @@
|
||||
"Create your first system" = "Erstelle dein erstes System";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
|
||||
"Add your first component" = "Erstelle deine erste Komponente";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Komponenten sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
|
||||
"Create Component" = "Komponente erstellen";
|
||||
"Browse Library" = "Bibliothek durchsuchen";
|
||||
"Browse" = "Durchsuchen";
|
||||
"Browse electrical components from VoltPlan" = "Elektrische Komponenten von VoltPlan durchstöbern";
|
||||
"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern";
|
||||
"Component Library" = "Komponentenbibliothek";
|
||||
"Details coming soon" = "Details folgen in Kürze";
|
||||
"Components" = "Komponenten";
|
||||
"Components" = "Verbraucher";
|
||||
"FUSE" = "SICHERUNG";
|
||||
"WIRE" = "KABEL";
|
||||
"Current" = "Strom";
|
||||
@@ -135,3 +135,48 @@
|
||||
"Color" = "Farbe";
|
||||
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
||||
"New Load" = "Neuer Verbraucher";
|
||||
|
||||
"tab.components" = "Verbraucher";
|
||||
"tab.batteries" = "Batterien";
|
||||
"tab.chargers" = "Ladegeräte";
|
||||
|
||||
"battery.bank.header.title" = "Batteriebank";
|
||||
"battery.bank.metric.count" = "Batterien";
|
||||
"battery.bank.metric.capacity" = "Kapazität";
|
||||
"battery.bank.metric.energy" = "Energie";
|
||||
"battery.bank.badge.voltage" = "Spannung";
|
||||
"battery.bank.badge.capacity" = "Kapazität";
|
||||
"battery.bank.badge.energy" = "Energie";
|
||||
"battery.bank.banner.voltage" = "Spannungsabweichung erkannt";
|
||||
"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt";
|
||||
"battery.bank.empty.title" = "Noch keine Batterien";
|
||||
"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren.";
|
||||
"battery.bank.status.dismiss" = "Verstanden";
|
||||
"battery.bank.status.single.battery" = "Eine Batterie";
|
||||
"battery.bank.status.multiple.batteries" = "%d Batterien";
|
||||
"battery.bank.status.voltage.title" = "Spannungsabweichung";
|
||||
"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen.";
|
||||
"battery.bank.status.capacity.title" = "Kapazitätsabweichung";
|
||||
"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung.";
|
||||
|
||||
"battery.editor.title" = "Batterie einrichten";
|
||||
"battery.editor.cancel" = "Abbrechen";
|
||||
"battery.editor.save" = "Speichern";
|
||||
"battery.editor.field.name" = "Name";
|
||||
"battery.editor.placeholder.name" = "Hausbank";
|
||||
"battery.editor.field.chemistry" = "Chemie";
|
||||
"battery.editor.section.summary" = "Übersicht";
|
||||
"battery.editor.slider.voltage" = "Nennspannung";
|
||||
"battery.editor.slider.capacity" = "Kapazität";
|
||||
"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten";
|
||||
"battery.editor.alert.voltage.placeholder" = "Spannung";
|
||||
"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
|
||||
"battery.editor.alert.capacity.title" = "Kapazität bearbeiten";
|
||||
"battery.editor.alert.capacity.placeholder" = "Kapazität";
|
||||
"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben";
|
||||
"battery.editor.alert.cancel" = "Abbrechen";
|
||||
"battery.editor.alert.save" = "Speichern";
|
||||
"battery.editor.default_name" = "Neue Batterie";
|
||||
|
||||
"chargers.title" = "Ladegeräte für %@";
|
||||
"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar.";
|
||||
|
||||
@@ -134,3 +134,48 @@
|
||||
"Color" = "Color";
|
||||
"VoltPlan Library" = "Biblioteca de VoltPlan";
|
||||
"New Load" = "Carga nueva";
|
||||
|
||||
"tab.components" = "Componentes";
|
||||
"tab.batteries" = "Baterías";
|
||||
"tab.chargers" = "Cargadores";
|
||||
|
||||
"battery.bank.header.title" = "Banco de baterías";
|
||||
"battery.bank.metric.count" = "Baterías";
|
||||
"battery.bank.metric.capacity" = "Capacidad";
|
||||
"battery.bank.metric.energy" = "Energía";
|
||||
"battery.bank.badge.voltage" = "Voltaje";
|
||||
"battery.bank.badge.capacity" = "Capacidad";
|
||||
"battery.bank.badge.energy" = "Energía";
|
||||
"battery.bank.banner.voltage" = "Se detectó un desajuste de voltaje";
|
||||
"battery.bank.banner.capacity" = "Se detectó un desajuste de capacidad";
|
||||
"battery.bank.empty.title" = "Sin baterías todavía";
|
||||
"battery.bank.empty.subtitle" = "Toca el botón más para configurar una batería para %@.";
|
||||
"battery.bank.status.dismiss" = "Entendido";
|
||||
"battery.bank.status.single.battery" = "Una batería";
|
||||
"battery.bank.status.multiple.batteries" = "%d baterías";
|
||||
"battery.bank.status.voltage.title" = "Desajuste de voltaje";
|
||||
"battery.bank.status.voltage.message" = "%@ se desvía del voltaje base del banco %@. Mezclar voltajes nominales provoca carga desigual y puede dañar cargadores o inversores conectados.";
|
||||
"battery.bank.status.capacity.title" = "Desajuste de capacidad";
|
||||
"battery.bank.status.capacity.message" = "%@ usa una capacidad distinta del valor dominante del banco %@. Las capacidades desiguales provocan descargas irregulares y desgaste prematuro.";
|
||||
|
||||
"battery.editor.title" = "Configuración de batería";
|
||||
"battery.editor.cancel" = "Cancelar";
|
||||
"battery.editor.save" = "Guardar";
|
||||
"battery.editor.field.name" = "Nombre";
|
||||
"battery.editor.placeholder.name" = "Banco principal";
|
||||
"battery.editor.field.chemistry" = "Química";
|
||||
"battery.editor.section.summary" = "Resumen";
|
||||
"battery.editor.slider.voltage" = "Voltaje nominal";
|
||||
"battery.editor.slider.capacity" = "Capacidad";
|
||||
"battery.editor.alert.voltage.title" = "Editar voltaje nominal";
|
||||
"battery.editor.alert.voltage.placeholder" = "Voltaje";
|
||||
"battery.editor.alert.voltage.message" = "Introduce el voltaje en voltios (V)";
|
||||
"battery.editor.alert.capacity.title" = "Editar capacidad";
|
||||
"battery.editor.alert.capacity.placeholder" = "Capacidad";
|
||||
"battery.editor.alert.capacity.message" = "Introduce la capacidad en amperios-hora (Ah)";
|
||||
"battery.editor.alert.cancel" = "Cancelar";
|
||||
"battery.editor.alert.save" = "Guardar";
|
||||
"battery.editor.default_name" = "Nueva batería";
|
||||
|
||||
"chargers.title" = "Cargadores para %@";
|
||||
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto.";
|
||||
|
||||
@@ -134,3 +134,48 @@
|
||||
"Color" = "Couleur";
|
||||
"VoltPlan Library" = "Bibliothèque VoltPlan";
|
||||
"New Load" = "Nouvelle charge";
|
||||
|
||||
"tab.components" = "Composants";
|
||||
"tab.batteries" = "Batteries";
|
||||
"tab.chargers" = "Chargeurs";
|
||||
|
||||
"battery.bank.header.title" = "Banque de batteries";
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
"battery.bank.metric.capacity" = "Capacité";
|
||||
"battery.bank.metric.energy" = "Énergie";
|
||||
"battery.bank.badge.voltage" = "Tension";
|
||||
"battery.bank.badge.capacity" = "Capacité";
|
||||
"battery.bank.badge.energy" = "Énergie";
|
||||
"battery.bank.banner.voltage" = "Écart de tension détecté";
|
||||
"battery.bank.banner.capacity" = "Écart de capacité détecté";
|
||||
"battery.bank.empty.title" = "Aucune batterie pour l'instant";
|
||||
"battery.bank.empty.subtitle" = "Touchez le bouton plus pour configurer une batterie pour %@.";
|
||||
"battery.bank.status.dismiss" = "Compris";
|
||||
"battery.bank.status.single.battery" = "Une batterie";
|
||||
"battery.bank.status.multiple.batteries" = "%d batteries";
|
||||
"battery.bank.status.voltage.title" = "Écart de tension";
|
||||
"battery.bank.status.voltage.message" = "%@ s'écarte de la valeur de référence %@ du banc. Mélanger des tensions nominales entraîne une charge inégale et peut endommager les chargeurs ou onduleurs connectés.";
|
||||
"battery.bank.status.capacity.title" = "Écart de capacité";
|
||||
"battery.bank.status.capacity.message" = "%@ utilise une capacité différente de la valeur dominante %@ du banc. Des capacités différentes provoquent des décharges inégales et une usure prématurée.";
|
||||
|
||||
"battery.editor.title" = "Configuration de la batterie";
|
||||
"battery.editor.cancel" = "Annuler";
|
||||
"battery.editor.save" = "Enregistrer";
|
||||
"battery.editor.field.name" = "Nom";
|
||||
"battery.editor.placeholder.name" = "Banque principale";
|
||||
"battery.editor.field.chemistry" = "Chimie";
|
||||
"battery.editor.section.summary" = "Résumé";
|
||||
"battery.editor.slider.voltage" = "Tension nominale";
|
||||
"battery.editor.slider.capacity" = "Capacité";
|
||||
"battery.editor.alert.voltage.title" = "Modifier la tension nominale";
|
||||
"battery.editor.alert.voltage.placeholder" = "Tension";
|
||||
"battery.editor.alert.voltage.message" = "Saisissez la tension en volts (V)";
|
||||
"battery.editor.alert.capacity.title" = "Modifier la capacité";
|
||||
"battery.editor.alert.capacity.placeholder" = "Capacité";
|
||||
"battery.editor.alert.capacity.message" = "Saisissez la capacité en ampères-heures (Ah)";
|
||||
"battery.editor.alert.cancel" = "Annuler";
|
||||
"battery.editor.alert.save" = "Enregistrer";
|
||||
"battery.editor.default_name" = "Nouvelle batterie";
|
||||
|
||||
"chargers.title" = "Chargeurs pour %@";
|
||||
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
|
||||
|
||||
@@ -134,3 +134,48 @@
|
||||
"Color" = "Kleur";
|
||||
"VoltPlan Library" = "VoltPlan-bibliotheek";
|
||||
"New Load" = "Nieuwe last";
|
||||
|
||||
"tab.components" = "Componenten";
|
||||
"tab.batteries" = "Batterijen";
|
||||
"tab.chargers" = "Laders";
|
||||
|
||||
"battery.bank.header.title" = "Accubank";
|
||||
"battery.bank.metric.count" = "Batterijen";
|
||||
"battery.bank.metric.capacity" = "Capaciteit";
|
||||
"battery.bank.metric.energy" = "Energie";
|
||||
"battery.bank.badge.voltage" = "Spanning";
|
||||
"battery.bank.badge.capacity" = "Capaciteit";
|
||||
"battery.bank.badge.energy" = "Energie";
|
||||
"battery.bank.banner.voltage" = "Spanningsafwijking gedetecteerd";
|
||||
"battery.bank.banner.capacity" = "Capaciteitsafwijking gedetecteerd";
|
||||
"battery.bank.empty.title" = "Nog geen batterijen";
|
||||
"battery.bank.empty.subtitle" = "Tik op de plusknop om een batterij voor %@ te configureren.";
|
||||
"battery.bank.status.dismiss" = "Begrepen";
|
||||
"battery.bank.status.single.battery" = "Eén batterij";
|
||||
"battery.bank.status.multiple.batteries" = "%d batterijen";
|
||||
"battery.bank.status.voltage.title" = "Spanningsafwijking";
|
||||
"battery.bank.status.voltage.message" = "%@ wijkt af van de basiswaarde %@ van de bank. Verschillende nominale spanningen zorgen voor ongelijk laden en kunnen aangesloten laders of omvormers beschadigen.";
|
||||
"battery.bank.status.capacity.title" = "Capaciteitsafwijking";
|
||||
"battery.bank.status.capacity.message" = "%@ gebruikt een andere capaciteit dan de dominante bankwaarde %@. Verschillende capaciteiten zorgen voor ongelijk ontladen en vroegtijdige slijtage.";
|
||||
|
||||
"battery.editor.title" = "Batterij configureren";
|
||||
"battery.editor.cancel" = "Annuleren";
|
||||
"battery.editor.save" = "Opslaan";
|
||||
"battery.editor.field.name" = "Naam";
|
||||
"battery.editor.placeholder.name" = "Huishoudbank";
|
||||
"battery.editor.field.chemistry" = "Chemie";
|
||||
"battery.editor.section.summary" = "Overzicht";
|
||||
"battery.editor.slider.voltage" = "Nominale spanning";
|
||||
"battery.editor.slider.capacity" = "Capaciteit";
|
||||
"battery.editor.alert.voltage.title" = "Nominale spanning bewerken";
|
||||
"battery.editor.alert.voltage.placeholder" = "Spanning";
|
||||
"battery.editor.alert.voltage.message" = "Voer de spanning in volt (V) in";
|
||||
"battery.editor.alert.capacity.title" = "Capaciteit bewerken";
|
||||
"battery.editor.alert.capacity.placeholder" = "Capaciteit";
|
||||
"battery.editor.alert.capacity.message" = "Voer de capaciteit in ampère-uur (Ah) in";
|
||||
"battery.editor.alert.cancel" = "Annuleren";
|
||||
"battery.editor.alert.save" = "Opslaan";
|
||||
"battery.editor.default_name" = "Nieuwe batterij";
|
||||
|
||||
"chargers.title" = "Laders voor %@";
|
||||
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";
|
||||
|
||||
Reference in New Issue
Block a user