Compare commits
7 Commits
858bf2a305
...
cd8a043c5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8a043c5c | ||
|
|
0720529821 | ||
|
|
6258a6a66f | ||
|
|
802b111aa7 | ||
|
|
c7ff9322ef | ||
|
|
d081a79b59 | ||
|
|
9f8d8e5149 |
@@ -405,7 +405,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -422,7 +422,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -440,7 +440,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
CURRENT_PROJECT_VERSION = 28;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -457,7 +457,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 1.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.92941,1.00000,0.92941,1.00000",
|
||||
"display-p3:0.94971,1.00000,0.96298,1.00000",
|
||||
"extended-gray:1.00000,1.00000"
|
||||
]
|
||||
},
|
||||
|
||||
52
Cable/Assets.xcassets/battery-onboarding.imageset/Contents.json
vendored
Normal file
52
Cable/Assets.xcassets/battery-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "battery-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "battery-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/battery-onboarding.imageset/battery-dark.png
vendored
Normal file
BIN
Cable/Assets.xcassets/battery-onboarding.imageset/battery-dark.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
Cable/Assets.xcassets/battery-onboarding.imageset/battery-light.png
vendored
Normal file
BIN
Cable/Assets.xcassets/battery-onboarding.imageset/battery-light.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@@ -77,6 +77,12 @@
|
||||
"loads.overview.metric.count" = "Loads";
|
||||
"loads.overview.metric.current" = "Total Current";
|
||||
"loads.overview.metric.power" = "Total Power";
|
||||
"loads.overview.empty.message" = "Start by adding a load to see system insights.";
|
||||
"loads.overview.empty.create" = "Add Load";
|
||||
"loads.overview.empty.library" = "Browse Library";
|
||||
"loads.library.button" = "Library";
|
||||
"loads.onboarding.title" = "Add your first component";
|
||||
"loads.onboarding.subtitle" = "Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.";
|
||||
"loads.overview.status.missing_details.title" = "Missing load details";
|
||||
"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations.";
|
||||
"loads.overview.status.missing_details.singular" = "load";
|
||||
@@ -98,6 +104,9 @@
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
"battery.bank.metric.capacity" = "Capacity";
|
||||
"battery.bank.metric.energy" = "Energy";
|
||||
"battery.overview.empty.create" = "Add Battery";
|
||||
"battery.onboarding.title" = "Add your first battery";
|
||||
"battery.onboarding.subtitle" = "Track your bank's capacity and chemistry to keep runtime expectations in check.";
|
||||
"battery.bank.badge.voltage" = "Voltage";
|
||||
"battery.bank.badge.capacity" = "Capacity";
|
||||
"battery.bank.badge.energy" = "Energy";
|
||||
@@ -132,5 +141,48 @@
|
||||
"battery.editor.alert.save" = "Save";
|
||||
"battery.editor.default_name" = "New Battery";
|
||||
|
||||
"charger.editor.title" = "Charger";
|
||||
"charger.editor.field.name" = "Name";
|
||||
"charger.editor.placeholder.name" = "Workshop Charger";
|
||||
"charger.editor.section.electrical" = "Electrical";
|
||||
"charger.editor.section.power" = "Charge Output";
|
||||
"charger.editor.appearance.title" = "Charger Appearance";
|
||||
"charger.editor.appearance.subtitle" = "Customize how this charger shows up";
|
||||
"charger.editor.appearance.accessibility" = "Edit charger appearance";
|
||||
"charger.editor.field.input_voltage" = "Input Voltage";
|
||||
"charger.editor.field.output_voltage" = "Output Voltage";
|
||||
"charger.editor.field.current" = "Charge Current";
|
||||
"charger.editor.field.power" = "Charge Power";
|
||||
"charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current.";
|
||||
"charger.editor.default_name" = "New Charger";
|
||||
"charger.editor.alert.input_voltage.title" = "Edit Input Voltage";
|
||||
"charger.editor.alert.output_voltage.title" = "Edit Output Voltage";
|
||||
"charger.editor.alert.current.title" = "Edit Charge Current";
|
||||
"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)";
|
||||
"charger.editor.alert.power.title" = "Edit Charge Power";
|
||||
"charger.editor.alert.power.placeholder" = "Power";
|
||||
"charger.editor.alert.power.message" = "Enter power in watts (W)";
|
||||
"charger.editor.alert.current.message" = "Enter current in amps (A)";
|
||||
"charger.editor.alert.cancel" = "Cancel";
|
||||
"charger.editor.alert.save" = "Save";
|
||||
"charger.default.new" = "New Charger";
|
||||
|
||||
"chargers.summary.title" = "Charging Overview";
|
||||
"chargers.summary.metric.count" = "Chargers";
|
||||
"chargers.summary.metric.output" = "Output Voltage";
|
||||
"chargers.summary.metric.current" = "Charge Rate";
|
||||
"chargers.summary.metric.power" = "Charge Power";
|
||||
"chargers.badge.input" = "Input";
|
||||
"chargers.badge.output" = "Output";
|
||||
"chargers.badge.current" = "Current";
|
||||
"chargers.badge.power" = "Power";
|
||||
"chargers.onboarding.title" = "Add your chargers";
|
||||
"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity.";
|
||||
"chargers.onboarding.primary" = "Create Charger";
|
||||
|
||||
"sample.charger.shore.name" = "Shore power charger";
|
||||
"sample.charger.dcdc.name" = "DC-DC charger";
|
||||
"sample.charger.workbench.name" = "Workbench charger";
|
||||
|
||||
"chargers.title" = "Chargers for %@";
|
||||
"chargers.subtitle" = "Charger components will be available soon.";
|
||||
|
||||
@@ -255,7 +255,7 @@ struct BatteriesView: View {
|
||||
private func batteryRow(for battery: SavedBattery) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
batteryIcon
|
||||
batteryIcon(for: battery)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
@@ -312,12 +312,12 @@ struct BatteriesView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var batteryIcon: some View {
|
||||
private func batteryIcon(for battery: SavedBattery) -> some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(colorForName(system.colorName))
|
||||
.fill(Color.componentColor(named: battery.colorName))
|
||||
.frame(width: 48, height: 48)
|
||||
Image(systemName: "battery.100.bolt")
|
||||
Image(systemName: battery.iconName.isEmpty ? "battery.100.bolt" : battery.iconName)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(Color.white)
|
||||
}
|
||||
@@ -336,36 +336,19 @@ struct BatteriesView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ComponentSummaryMetricView(
|
||||
icon: icon,
|
||||
label: label,
|
||||
value: value,
|
||||
tint: tint
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
ComponentMetricBadgeView(
|
||||
label: label,
|
||||
value: value,
|
||||
tint: tint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -387,25 +370,6 @@ struct BatteriesView: View {
|
||||
)
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -589,6 +553,8 @@ private enum BatteriesViewPreviewData {
|
||||
nominalVoltage: 12.8,
|
||||
capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt",
|
||||
colorName: "green",
|
||||
system: system
|
||||
),
|
||||
SavedBattery(
|
||||
@@ -596,6 +562,8 @@ private enum BatteriesViewPreviewData {
|
||||
nominalVoltage: 12.0,
|
||||
capacityAmpHours: 90,
|
||||
chemistry: .agm,
|
||||
iconName: "bolt",
|
||||
colorName: "orange",
|
||||
system: system
|
||||
)
|
||||
]
|
||||
@@ -21,6 +21,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
var nominalVoltage: Double
|
||||
var capacityAmpHours: Double
|
||||
var chemistry: Chemistry
|
||||
var iconName: String
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
|
||||
init(
|
||||
@@ -29,6 +31,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
nominalVoltage: Double = 12.8,
|
||||
capacityAmpHours: Double = 100,
|
||||
chemistry: Chemistry = .lithiumIronPhosphate,
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem
|
||||
) {
|
||||
self.id = id
|
||||
@@ -36,6 +40,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
self.nominalVoltage = nominalVoltage
|
||||
self.capacityAmpHours = capacityAmpHours
|
||||
self.chemistry = chemistry
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
}
|
||||
|
||||
@@ -45,6 +51,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
self.nominalVoltage = savedBattery.nominalVoltage
|
||||
self.capacityAmpHours = savedBattery.capacityAmpHours
|
||||
self.chemistry = savedBattery.chemistry
|
||||
self.iconName = savedBattery.iconName
|
||||
self.colorName = savedBattery.colorName
|
||||
self.system = system
|
||||
}
|
||||
|
||||
@@ -57,6 +65,8 @@ struct BatteryConfiguration: Identifiable, Hashable {
|
||||
savedBattery.nominalVoltage = nominalVoltage
|
||||
savedBattery.capacityAmpHours = capacityAmpHours
|
||||
savedBattery.chemistry = chemistry
|
||||
savedBattery.iconName = iconName
|
||||
savedBattery.colorName = colorName
|
||||
savedBattery.system = system
|
||||
savedBattery.timestamp = Date()
|
||||
}
|
||||
@@ -68,7 +78,9 @@ extension BatteryConfiguration {
|
||||
lhs.name == rhs.name &&
|
||||
lhs.nominalVoltage == rhs.nominalVoltage &&
|
||||
lhs.capacityAmpHours == rhs.capacityAmpHours &&
|
||||
lhs.chemistry == rhs.chemistry
|
||||
lhs.chemistry == rhs.chemistry &&
|
||||
lhs.iconName == rhs.iconName &&
|
||||
lhs.colorName == rhs.colorName
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
@@ -77,5 +89,7 @@ extension BatteryConfiguration {
|
||||
hasher.combine(nominalVoltage)
|
||||
hasher.combine(capacityAmpHours)
|
||||
hasher.combine(chemistry)
|
||||
hasher.combine(iconName)
|
||||
hasher.combine(colorName)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ struct BatteryEditorView: View {
|
||||
@State private var editingField: EditingField?
|
||||
@State private var voltageInput: String = ""
|
||||
@State private var capacityInput: String = ""
|
||||
@State private var showingAppearanceEditor = false
|
||||
let onSave: (BatteryConfiguration) -> Void
|
||||
|
||||
private enum EditingField {
|
||||
@@ -16,6 +17,21 @@ struct BatteryEditorView: View {
|
||||
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 let batteryIconOptions: [String] = [
|
||||
"battery.100",
|
||||
"battery.100.bolt",
|
||||
"battery.75",
|
||||
"battery.25",
|
||||
"battery.0",
|
||||
"bolt",
|
||||
"bolt.fill",
|
||||
"bolt.circle",
|
||||
"bolt.horizontal.circle",
|
||||
"powerplug",
|
||||
"car.battery",
|
||||
"bolt.square",
|
||||
"lightbulb"
|
||||
]
|
||||
|
||||
private var nameFieldLabel: String {
|
||||
String(
|
||||
@@ -88,6 +104,41 @@ struct BatteryEditorView: View {
|
||||
comment: "Label used for energy values"
|
||||
)
|
||||
}
|
||||
|
||||
private var appearanceEditorTitle: String {
|
||||
NSLocalizedString(
|
||||
"battery.editor.appearance.title",
|
||||
bundle: .main,
|
||||
value: "Battery Appearance",
|
||||
comment: "Title for the battery appearance editor"
|
||||
)
|
||||
}
|
||||
|
||||
private var appearanceEditorSubtitle: String {
|
||||
NSLocalizedString(
|
||||
"battery.editor.appearance.subtitle",
|
||||
bundle: .main,
|
||||
value: "Customize how this battery shows up",
|
||||
comment: "Subtitle shown in the battery appearance editor preview"
|
||||
)
|
||||
}
|
||||
|
||||
private var appearanceAccessibilityLabel: String {
|
||||
NSLocalizedString(
|
||||
"battery.editor.appearance.accessibility",
|
||||
bundle: .main,
|
||||
value: "Edit battery appearance",
|
||||
comment: "Accessibility label for the battery appearance editor button"
|
||||
)
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
Color.componentColor(named: configuration.colorName)
|
||||
}
|
||||
|
||||
private var displayName: String {
|
||||
configuration.name.isEmpty ? namePlaceholder : configuration.name
|
||||
}
|
||||
|
||||
private var voltageSliderRange: ClosedRange<Double> {
|
||||
let lowerBound = max(0, min(6, configuration.nominalVoltage))
|
||||
@@ -107,27 +158,48 @@ struct BatteryEditorView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
headerCard
|
||||
slidersSection
|
||||
VStack(spacing: 0) {
|
||||
headerInfoBar
|
||||
List {
|
||||
configurationSection
|
||||
sliderSection
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.padding(.horizontal)
|
||||
.listStyle(.plain)
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle(
|
||||
NSLocalizedString(
|
||||
"battery.editor.title",
|
||||
bundle: .main,
|
||||
value: "Battery Setup",
|
||||
comment: "Title for the battery editor"
|
||||
)
|
||||
)
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
navigationTitleView
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
onSave(configuration)
|
||||
}
|
||||
.sheet(isPresented: $showingAppearanceEditor) {
|
||||
ItemEditorView(
|
||||
title: appearanceEditorTitle,
|
||||
nameFieldLabel: nameFieldLabel,
|
||||
previewSubtitle: appearanceEditorSubtitle,
|
||||
icons: batteryIconOptions,
|
||||
name: Binding(
|
||||
get: { configuration.name },
|
||||
set: { configuration.name = $0 }
|
||||
),
|
||||
iconName: Binding(
|
||||
get: { configuration.iconName },
|
||||
set: { configuration.iconName = $0 }
|
||||
),
|
||||
colorName: Binding(
|
||||
get: { configuration.colorName },
|
||||
set: { configuration.colorName = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.alert(
|
||||
NSLocalizedString(
|
||||
"battery.editor.alert.voltage.title",
|
||||
@@ -160,7 +232,7 @@ struct BatteryEditorView: View {
|
||||
voltageInput = formattedEditValue(configuration.nominalVoltage)
|
||||
}
|
||||
}
|
||||
.onChange(of: voltageInput) { newValue in
|
||||
.onChange(of: voltageInput) { _, newValue in
|
||||
guard editingField == .voltage, let parsed = parseInput(newValue) else { return }
|
||||
configuration.nominalVoltage = roundToTenth(parsed)
|
||||
}
|
||||
@@ -234,7 +306,7 @@ struct BatteryEditorView: View {
|
||||
capacityInput = formattedEditValue(configuration.capacityAmpHours)
|
||||
}
|
||||
}
|
||||
.onChange(of: capacityInput) { newValue in
|
||||
.onChange(of: capacityInput) { _, newValue in
|
||||
guard editingField == .capacity, let parsed = parseInput(newValue) else { return }
|
||||
configuration.capacityAmpHours = roundToTenth(parsed)
|
||||
}
|
||||
@@ -278,26 +350,32 @@ struct BatteryEditorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
)
|
||||
private var navigationTitleView: some View {
|
||||
Button {
|
||||
showingAppearanceEditor = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
LoadIconView(
|
||||
remoteIconURLString: nil,
|
||||
fallbackSystemName: configuration.iconName.isEmpty ? "battery.100.bolt" : configuration.iconName,
|
||||
fallbackColor: iconColor,
|
||||
size: 26
|
||||
)
|
||||
Text(displayName)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(appearanceAccessibilityLabel)
|
||||
}
|
||||
|
||||
private var configurationSection: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(chemistryLabel)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(chemistryLabel.uppercased())
|
||||
.font(.headline)
|
||||
Menu {
|
||||
ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in
|
||||
Button {
|
||||
@@ -313,25 +391,29 @@ struct BatteryEditorView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
Text(configuration.chemistry.displayName)
|
||||
.font(.body.weight(.semibold))
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
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)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(summaryLabel)
|
||||
.font(.caption)
|
||||
private var summarySection: some View {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(summaryLabel.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
@@ -371,16 +453,15 @@ struct BatteryEditorView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.tertiarySystemBackground))
|
||||
)
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
|
||||
private var slidersSection: some View {
|
||||
VStack(spacing: 30) {
|
||||
private var sliderSection: some View {
|
||||
Section {
|
||||
SliderSection(
|
||||
title: sliderVoltageTitle,
|
||||
value: Binding(
|
||||
@@ -398,6 +479,7 @@ struct BatteryEditorView: View {
|
||||
tapAction: beginVoltageEditing,
|
||||
snapValues: editingField == .voltage ? nil : voltageSnapValues
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
SliderSection(
|
||||
title: sliderCapacityTitle,
|
||||
@@ -416,11 +498,62 @@ struct BatteryEditorView: View {
|
||||
tapAction: beginCapacityEditing,
|
||||
snapValues: editingField == .capacity ? nil : capacitySnapValues
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
.padding()
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
|
||||
private var headerInfoBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
overviewChip(
|
||||
icon: "bolt.fill",
|
||||
title: summaryVoltageLabel.uppercased(),
|
||||
value: formattedValue(configuration.nominalVoltage, unit: "V"),
|
||||
tint: .orange
|
||||
)
|
||||
|
||||
overviewChip(
|
||||
icon: "gauge.medium",
|
||||
title: summaryCapacityLabel.uppercased(),
|
||||
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
|
||||
tint: .blue
|
||||
)
|
||||
|
||||
overviewChip(
|
||||
icon: "battery.100.bolt",
|
||||
title: summaryEnergyLabel.uppercased(),
|
||||
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
|
||||
tint: .purple
|
||||
)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private func overviewChip(icon: String, title: String, value: String, tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,7 +649,12 @@ struct BatteryEditorView: View {
|
||||
let previewSystem = ElectricalSystem(name: "Camper")
|
||||
return NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
|
||||
configuration: BatteryConfiguration(
|
||||
name: "House Bank",
|
||||
iconName: "battery.100.bolt",
|
||||
colorName: "green",
|
||||
system: previewSystem
|
||||
),
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
@@ -15,13 +15,13 @@ struct CableApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
do {
|
||||
// Try the simple approach first
|
||||
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, Item.self)
|
||||
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.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, SavedBattery.self, Item.self])
|
||||
let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
|
||||
67
Cable/Chargers/ChargerConfiguration.swift
Normal file
67
Cable/Chargers/ChargerConfiguration.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct ChargerConfiguration: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var inputVoltage: Double
|
||||
var outputVoltage: Double
|
||||
var maxCurrentAmps: Double
|
||||
var maxPowerWatts: Double
|
||||
var iconName: String
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
inputVoltage: Double = 230.0,
|
||||
outputVoltage: Double = 14.2,
|
||||
maxCurrentAmps: Double = 30.0,
|
||||
maxPowerWatts: Double = 0.0,
|
||||
iconName: String = "bolt.fill",
|
||||
colorName: String = "orange",
|
||||
system: ElectricalSystem
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.inputVoltage = inputVoltage
|
||||
self.outputVoltage = outputVoltage
|
||||
self.maxCurrentAmps = maxCurrentAmps
|
||||
self.maxPowerWatts = maxPowerWatts
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
}
|
||||
|
||||
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
||||
self.id = savedCharger.id
|
||||
self.name = savedCharger.name
|
||||
self.inputVoltage = savedCharger.inputVoltage
|
||||
self.outputVoltage = savedCharger.outputVoltage
|
||||
self.maxCurrentAmps = savedCharger.maxCurrentAmps
|
||||
self.maxPowerWatts = savedCharger.maxPowerWatts
|
||||
self.iconName = savedCharger.iconName
|
||||
self.colorName = savedCharger.colorName
|
||||
self.system = system
|
||||
}
|
||||
|
||||
var effectivePowerWatts: Double {
|
||||
if maxPowerWatts > 0 {
|
||||
return maxPowerWatts
|
||||
}
|
||||
return outputVoltage * maxCurrentAmps
|
||||
}
|
||||
|
||||
func apply(to savedCharger: SavedCharger) {
|
||||
savedCharger.name = name
|
||||
savedCharger.inputVoltage = inputVoltage
|
||||
savedCharger.outputVoltage = outputVoltage
|
||||
savedCharger.maxCurrentAmps = maxCurrentAmps
|
||||
savedCharger.maxPowerWatts = maxPowerWatts
|
||||
savedCharger.iconName = iconName
|
||||
savedCharger.colorName = colorName
|
||||
savedCharger.system = system
|
||||
savedCharger.timestamp = Date()
|
||||
}
|
||||
}
|
||||
952
Cable/Chargers/ChargerEditorView.swift
Normal file
952
Cable/Chargers/ChargerEditorView.swift
Normal file
@@ -0,0 +1,952 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChargerEditorView: View {
|
||||
@State private var configuration: ChargerConfiguration
|
||||
@State private var editingField: EditingField?
|
||||
@State private var inputVoltageInput: String = ""
|
||||
@State private var outputVoltageInput: String = ""
|
||||
@State private var currentInput: String = ""
|
||||
@State private var powerInput: String = ""
|
||||
@State private var powerEntryMode: PowerEntryMode
|
||||
@State private var lastManualPowerWatts: Double
|
||||
@State private var showingAppearanceEditor = false
|
||||
let onSave: (ChargerConfiguration) -> Void
|
||||
|
||||
private enum EditingField {
|
||||
case inputVoltage
|
||||
case outputVoltage
|
||||
case current
|
||||
case power
|
||||
}
|
||||
|
||||
private enum PowerEntryMode {
|
||||
case current
|
||||
case power
|
||||
}
|
||||
|
||||
private let inputVoltageSnapValues: [Double] = [12, 24, 48, 120, 230, 240]
|
||||
private let outputVoltageSnapValues: [Double] = [12, 12.6, 12.8, 14.2, 24, 48]
|
||||
private let currentSnapValues: [Double] = [5, 10, 15, 20, 25, 30, 40, 50, 60, 80, 100, 150, 200]
|
||||
private let powerSnapValues: [Double] = [100, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000, 2500, 3000]
|
||||
private let inputVoltageSnapTolerance: Double = 2.0
|
||||
private let outputVoltageSnapTolerance: Double = 0.5
|
||||
private let currentSnapTolerance: Double = 2.0
|
||||
private let powerSnapTolerance: Double = 25.0
|
||||
private let chargerIconOptions: [String] = [
|
||||
"bolt.fill",
|
||||
"bolt",
|
||||
"bolt.circle",
|
||||
"bolt.circle.fill",
|
||||
"bolt.horizontal.circle",
|
||||
"bolt.square",
|
||||
"bolt.square.fill",
|
||||
"bolt.badge.clock",
|
||||
"bolt.badge.a",
|
||||
"powerplug",
|
||||
"flashlight.on.fill",
|
||||
"battery.100.bolt"
|
||||
]
|
||||
|
||||
private var nameFieldLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.field.name",
|
||||
bundle: .main,
|
||||
comment: "Label for the charger name text field"
|
||||
)
|
||||
}
|
||||
|
||||
private var namePlaceholder: String {
|
||||
String(
|
||||
localized: "charger.editor.placeholder.name",
|
||||
bundle: .main,
|
||||
comment: "Placeholder example for the charger name field"
|
||||
)
|
||||
}
|
||||
|
||||
private var electricalSectionLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.section.electrical",
|
||||
bundle: .main,
|
||||
comment: "Label for the electrical section"
|
||||
)
|
||||
}
|
||||
|
||||
private var chargingSectionLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.section.power",
|
||||
bundle: .main,
|
||||
comment: "Label for the charging output section"
|
||||
)
|
||||
}
|
||||
|
||||
private var inputVoltageLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.field.input_voltage",
|
||||
bundle: .main,
|
||||
comment: "Label for the input voltage slider"
|
||||
)
|
||||
}
|
||||
|
||||
private var outputVoltageLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.field.output_voltage",
|
||||
bundle: .main,
|
||||
comment: "Label for the output voltage slider"
|
||||
)
|
||||
}
|
||||
|
||||
private var currentLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.field.current",
|
||||
bundle: .main,
|
||||
comment: "Label for the charging current slider"
|
||||
)
|
||||
}
|
||||
|
||||
private var powerLabel: String {
|
||||
String(
|
||||
localized: "charger.editor.field.power",
|
||||
bundle: .main,
|
||||
comment: "Label for the optional power field"
|
||||
)
|
||||
}
|
||||
|
||||
private var powerFooter: String {
|
||||
String(
|
||||
localized: "charger.editor.field.power.footer",
|
||||
bundle: .main,
|
||||
comment: "Footer text describing how the optional power field works"
|
||||
)
|
||||
}
|
||||
|
||||
private var appearanceEditorTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.appearance.title",
|
||||
bundle: .main,
|
||||
value: "Charger Appearance",
|
||||
comment: "Title for the charger appearance editor"
|
||||
)
|
||||
}
|
||||
|
||||
private var appearanceEditorSubtitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.appearance.subtitle",
|
||||
bundle: .main,
|
||||
value: "Customize how this charger shows up",
|
||||
comment: "Subtitle shown in the charger appearance editor preview"
|
||||
)
|
||||
}
|
||||
|
||||
private var appearanceAccessibilityLabel: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.appearance.accessibility",
|
||||
bundle: .main,
|
||||
value: "Edit charger appearance",
|
||||
comment: "Accessibility label for the charger appearance editor button"
|
||||
)
|
||||
}
|
||||
|
||||
private var inputBadgeLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.input",
|
||||
bundle: .main,
|
||||
comment: "Label for input voltage badges"
|
||||
)
|
||||
}
|
||||
|
||||
private var outputBadgeLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.output",
|
||||
bundle: .main,
|
||||
comment: "Label for output voltage badges"
|
||||
)
|
||||
}
|
||||
|
||||
private var currentBadgeLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.current",
|
||||
bundle: .main,
|
||||
comment: "Label for charging current badges"
|
||||
)
|
||||
}
|
||||
|
||||
private var powerBadgeLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.power",
|
||||
bundle: .main,
|
||||
comment: "Label for charging power badges"
|
||||
)
|
||||
}
|
||||
|
||||
private var wattButtonTitle: String {
|
||||
String(
|
||||
localized: "slider.button.watt",
|
||||
bundle: .main,
|
||||
comment: "Button label when switching to power entry mode"
|
||||
)
|
||||
}
|
||||
|
||||
private var ampButtonTitle: String {
|
||||
String(
|
||||
localized: "slider.button.ampere",
|
||||
bundle: .main,
|
||||
comment: "Button label when switching to current entry mode"
|
||||
)
|
||||
}
|
||||
|
||||
private var inputVoltageAlertTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.input_voltage.title",
|
||||
bundle: .main,
|
||||
value: "Edit Input Voltage",
|
||||
comment: "Title for the input voltage edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var outputVoltageAlertTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.output_voltage.title",
|
||||
bundle: .main,
|
||||
value: "Edit Output Voltage",
|
||||
comment: "Title for the output voltage edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var currentAlertTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.current.title",
|
||||
bundle: .main,
|
||||
value: "Edit Charge Current",
|
||||
comment: "Title for the charging current edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var powerAlertTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.power.title",
|
||||
bundle: .main,
|
||||
value: "Edit Charge Power",
|
||||
comment: "Title for the power edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var alertMessageVoltage: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.voltage.message",
|
||||
bundle: .main,
|
||||
value: "Enter voltage in volts (V)",
|
||||
comment: "Message for voltage edit alerts"
|
||||
)
|
||||
}
|
||||
|
||||
private var alertMessagePower: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.power.message",
|
||||
bundle: .main,
|
||||
value: "Enter power in watts (W)",
|
||||
comment: "Message for the power edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var alertMessageCurrent: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.current.message",
|
||||
bundle: .main,
|
||||
value: "Enter current in amps (A)",
|
||||
comment: "Message for the current edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var alertCancelTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.cancel",
|
||||
bundle: .main,
|
||||
value: "Cancel",
|
||||
comment: "Title for cancel buttons in edit alerts"
|
||||
)
|
||||
}
|
||||
|
||||
private var alertSaveTitle: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.save",
|
||||
bundle: .main,
|
||||
value: "Save",
|
||||
comment: "Title for save buttons in edit alerts"
|
||||
)
|
||||
}
|
||||
|
||||
private var powerAlertPlaceholder: String {
|
||||
NSLocalizedString(
|
||||
"charger.editor.alert.power.placeholder",
|
||||
bundle: .main,
|
||||
value: "Power",
|
||||
comment: "Placeholder for the power edit alert"
|
||||
)
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
Color.componentColor(named: configuration.colorName)
|
||||
}
|
||||
|
||||
private var displayName: String {
|
||||
configuration.name.isEmpty ? namePlaceholder : configuration.name
|
||||
}
|
||||
|
||||
private var inputVoltageSliderRange: ClosedRange<Double> {
|
||||
let lowerBound = max(0, min(12, configuration.inputVoltage))
|
||||
let upperBound = max(300, configuration.inputVoltage)
|
||||
return lowerBound...upperBound
|
||||
}
|
||||
|
||||
private var outputVoltageSliderRange: ClosedRange<Double> {
|
||||
let lowerBound = max(0, min(10, configuration.outputVoltage))
|
||||
let upperBound = max(80, configuration.outputVoltage)
|
||||
return lowerBound...upperBound
|
||||
}
|
||||
|
||||
private var currentSliderRange: ClosedRange<Double> {
|
||||
let lowerBound = max(0, min(5, configuration.maxCurrentAmps))
|
||||
let upperBound = max(200, configuration.maxCurrentAmps)
|
||||
return lowerBound...upperBound
|
||||
}
|
||||
|
||||
private var powerSliderRange: ClosedRange<Double> {
|
||||
let effectivePower = configuration.effectivePowerWatts
|
||||
let upperBound = max(3000, max(configuration.maxPowerWatts, effectivePower))
|
||||
return 0...upperBound
|
||||
}
|
||||
|
||||
init(configuration: ChargerConfiguration, onSave: @escaping (ChargerConfiguration) -> Void) {
|
||||
var adjustedConfiguration = configuration
|
||||
if configuration.maxPowerWatts > 0 {
|
||||
let voltage = max(configuration.outputVoltage, 0.1)
|
||||
let derivedCurrent = configuration.maxPowerWatts / voltage
|
||||
let roundedCurrent = max(0, (derivedCurrent * 10).rounded() / 10)
|
||||
adjustedConfiguration.maxCurrentAmps = roundedCurrent
|
||||
}
|
||||
_configuration = State(initialValue: adjustedConfiguration)
|
||||
_powerEntryMode = State(initialValue: adjustedConfiguration.maxPowerWatts > 0 ? .power : .current)
|
||||
let initialPowerCandidate = adjustedConfiguration.maxPowerWatts > 0
|
||||
? adjustedConfiguration.maxPowerWatts
|
||||
: max(0, adjustedConfiguration.outputVoltage * adjustedConfiguration.maxCurrentAmps)
|
||||
let roundedInitialPower = max(0, (initialPowerCandidate / 5).rounded() * 5)
|
||||
let snapValues = [100.0, 150.0, 200.0, 250.0, 300.0, 400.0, 500.0, 600.0, 750.0, 1000.0, 1250.0, 1500.0, 2000.0, 2500.0, 3000.0]
|
||||
let closestSnap = snapValues.min { abs($0 - roundedInitialPower) < abs($1 - roundedInitialPower) }
|
||||
let normalizedInitialPower: Double
|
||||
if let closestSnap, abs(closestSnap - roundedInitialPower) <= 25.0 {
|
||||
normalizedInitialPower = closestSnap
|
||||
} else {
|
||||
normalizedInitialPower = roundedInitialPower
|
||||
}
|
||||
_lastManualPowerWatts = State(initialValue: normalizedInitialPower)
|
||||
self.onSave = onSave
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerInfoBar
|
||||
List {
|
||||
electricalSection
|
||||
chargingSection
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollIndicators(.hidden)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground).ignoresSafeArea())
|
||||
.navigationTitle("")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
navigationTitleView
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
onSave(configuration)
|
||||
}
|
||||
.sheet(isPresented: $showingAppearanceEditor) {
|
||||
ItemEditorView(
|
||||
title: appearanceEditorTitle,
|
||||
nameFieldLabel: nameFieldLabel,
|
||||
previewSubtitle: appearanceEditorSubtitle,
|
||||
icons: chargerIconOptions,
|
||||
name: Binding(
|
||||
get: { configuration.name },
|
||||
set: { configuration.name = $0 }
|
||||
),
|
||||
iconName: Binding(
|
||||
get: { configuration.iconName },
|
||||
set: { configuration.iconName = $0 }
|
||||
),
|
||||
colorName: Binding(
|
||||
get: { configuration.colorName },
|
||||
set: { configuration.colorName = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.alert(
|
||||
inputVoltageAlertTitle,
|
||||
isPresented: Binding(
|
||||
get: { editingField == .inputVoltage },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
editingField = nil
|
||||
inputVoltageInput = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
inputVoltageLabel,
|
||||
text: $inputVoltageInput
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
.onAppear {
|
||||
if inputVoltageInput.isEmpty {
|
||||
inputVoltageInput = formattedEditValue(configuration.inputVoltage)
|
||||
}
|
||||
}
|
||||
.onChange(of: inputVoltageInput) { _, newValue in
|
||||
guard editingField == .inputVoltage, let parsed = parseInput(newValue) else { return }
|
||||
configuration.inputVoltage = roundToTenth(parsed)
|
||||
}
|
||||
|
||||
Button(alertCancelTitle, role: .cancel) {
|
||||
editingField = nil
|
||||
inputVoltageInput = ""
|
||||
}
|
||||
|
||||
Button(alertSaveTitle) {
|
||||
if let parsed = parseInput(inputVoltageInput) {
|
||||
configuration.inputVoltage = roundToTenth(parsed)
|
||||
}
|
||||
editingField = nil
|
||||
inputVoltageInput = ""
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessageVoltage)
|
||||
}
|
||||
.alert(
|
||||
outputVoltageAlertTitle,
|
||||
isPresented: Binding(
|
||||
get: { editingField == .outputVoltage },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
editingField = nil
|
||||
outputVoltageInput = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
outputVoltageLabel,
|
||||
text: $outputVoltageInput
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
.onAppear {
|
||||
if outputVoltageInput.isEmpty {
|
||||
outputVoltageInput = formattedEditValue(configuration.outputVoltage)
|
||||
}
|
||||
}
|
||||
.onChange(of: outputVoltageInput) { _, newValue in
|
||||
guard editingField == .outputVoltage, let parsed = parseInput(newValue) else { return }
|
||||
configuration.outputVoltage = roundToTenth(parsed)
|
||||
if powerEntryMode == .power {
|
||||
synchronizeCurrentWithPower()
|
||||
} else {
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
Button(alertCancelTitle, role: .cancel) {
|
||||
editingField = nil
|
||||
outputVoltageInput = ""
|
||||
}
|
||||
|
||||
Button(alertSaveTitle) {
|
||||
if let parsed = parseInput(outputVoltageInput) {
|
||||
configuration.outputVoltage = roundToTenth(parsed)
|
||||
if powerEntryMode == .power {
|
||||
synchronizeCurrentWithPower()
|
||||
} else {
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
}
|
||||
editingField = nil
|
||||
outputVoltageInput = ""
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessageVoltage)
|
||||
}
|
||||
.alert(
|
||||
currentAlertTitle,
|
||||
isPresented: Binding(
|
||||
get: { editingField == .current },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
editingField = nil
|
||||
currentInput = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
currentLabel,
|
||||
text: $currentInput
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
.onAppear {
|
||||
if currentInput.isEmpty {
|
||||
currentInput = formattedEditValue(configuration.maxCurrentAmps)
|
||||
}
|
||||
}
|
||||
.onChange(of: currentInput) { _, newValue in
|
||||
guard editingField == .current, let parsed = parseInput(newValue) else { return }
|
||||
configuration.maxCurrentAmps = roundToTenth(parsed)
|
||||
configuration.maxPowerWatts = 0
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
|
||||
Button(alertCancelTitle, role: .cancel) {
|
||||
editingField = nil
|
||||
currentInput = ""
|
||||
}
|
||||
|
||||
Button(alertSaveTitle) {
|
||||
if let parsed = parseInput(currentInput) {
|
||||
configuration.maxCurrentAmps = roundToTenth(parsed)
|
||||
configuration.maxPowerWatts = 0
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
editingField = nil
|
||||
currentInput = ""
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessageCurrent)
|
||||
}
|
||||
.alert(
|
||||
powerAlertTitle,
|
||||
isPresented: Binding(
|
||||
get: { editingField == .power },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
editingField = nil
|
||||
powerInput = ""
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
TextField(
|
||||
powerAlertPlaceholder,
|
||||
text: $powerInput
|
||||
)
|
||||
.keyboardType(.decimalPad)
|
||||
.onAppear {
|
||||
if powerInput.isEmpty {
|
||||
powerInput = formattedPowerEditValue(displayedPowerValue)
|
||||
}
|
||||
}
|
||||
.onChange(of: powerInput) { _, newValue in
|
||||
guard editingField == .power, let parsed = parseInput(newValue) else { return }
|
||||
let normalized = normalizedPower(for: parsed)
|
||||
configuration.maxPowerWatts = normalized
|
||||
lastManualPowerWatts = normalized
|
||||
synchronizeCurrentWithPower()
|
||||
}
|
||||
|
||||
Button(alertCancelTitle, role: .cancel) {
|
||||
editingField = nil
|
||||
powerInput = ""
|
||||
}
|
||||
|
||||
Button(alertSaveTitle) {
|
||||
if let parsed = parseInput(powerInput) {
|
||||
let normalized = normalizedPower(for: parsed)
|
||||
configuration.maxPowerWatts = normalized
|
||||
lastManualPowerWatts = normalized
|
||||
powerEntryMode = .power
|
||||
synchronizeCurrentWithPower()
|
||||
}
|
||||
editingField = nil
|
||||
powerInput = ""
|
||||
}
|
||||
} message: {
|
||||
Text(alertMessagePower)
|
||||
}
|
||||
}
|
||||
|
||||
private var navigationTitleView: some View {
|
||||
Button {
|
||||
showingAppearanceEditor = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
LoadIconView(
|
||||
remoteIconURLString: nil,
|
||||
fallbackSystemName: configuration.iconName.isEmpty ? "bolt.fill" : configuration.iconName,
|
||||
fallbackColor: iconColor,
|
||||
size: 26
|
||||
)
|
||||
Text(displayName)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(appearanceAccessibilityLabel)
|
||||
}
|
||||
|
||||
private var electricalSection: some View {
|
||||
Section {
|
||||
SliderSection(
|
||||
title: inputVoltageLabel,
|
||||
value: Binding(
|
||||
get: { configuration.inputVoltage },
|
||||
set: { newValue in
|
||||
if editingField == .inputVoltage {
|
||||
configuration.inputVoltage = roundToTenth(newValue)
|
||||
} else {
|
||||
configuration.inputVoltage = normalizedInputVoltage(for: newValue)
|
||||
}
|
||||
}
|
||||
),
|
||||
range: inputVoltageSliderRange,
|
||||
unit: "V",
|
||||
tapAction: beginInputVoltageEditing,
|
||||
snapValues: editingField == .inputVoltage ? nil : inputVoltageSnapValues
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
|
||||
SliderSection(
|
||||
title: outputVoltageLabel,
|
||||
value: Binding(
|
||||
get: { configuration.outputVoltage },
|
||||
set: { newValue in
|
||||
if editingField == .outputVoltage {
|
||||
configuration.outputVoltage = roundToTenth(newValue)
|
||||
} else {
|
||||
configuration.outputVoltage = normalizedOutputVoltage(for: newValue)
|
||||
}
|
||||
if powerEntryMode == .power {
|
||||
synchronizeCurrentWithPower()
|
||||
} else {
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
}
|
||||
),
|
||||
range: outputVoltageSliderRange,
|
||||
unit: "V",
|
||||
tapAction: beginOutputVoltageEditing,
|
||||
snapValues: editingField == .outputVoltage ? nil : outputVoltageSnapValues
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
|
||||
private var chargingSection: some View {
|
||||
Section {
|
||||
if powerEntryMode == .power {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SliderSection(
|
||||
title: powerLabel,
|
||||
value: Binding(
|
||||
get: { displayedPowerValue },
|
||||
set: { newValue in
|
||||
let normalized = editingField == .power ? roundToNearestFive(newValue) : normalizedPower(for: newValue)
|
||||
configuration.maxPowerWatts = normalized
|
||||
lastManualPowerWatts = normalized
|
||||
synchronizeCurrentWithPower()
|
||||
}
|
||||
),
|
||||
range: powerSliderRange,
|
||||
unit: "W",
|
||||
buttonText: ampButtonTitle,
|
||||
buttonAction: switchToCurrentMode,
|
||||
tapAction: beginPowerEditing,
|
||||
snapValues: editingField == .power ? nil : powerSnapValues
|
||||
)
|
||||
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
} else {
|
||||
SliderSection(
|
||||
title: currentLabel,
|
||||
value: Binding(
|
||||
get: { configuration.maxCurrentAmps },
|
||||
set: { newValue in
|
||||
if editingField == .current {
|
||||
configuration.maxCurrentAmps = roundToTenth(newValue)
|
||||
} else {
|
||||
configuration.maxCurrentAmps = normalizedCurrent(for: newValue)
|
||||
}
|
||||
configuration.maxPowerWatts = 0
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
),
|
||||
range: currentSliderRange,
|
||||
unit: "A",
|
||||
buttonText: wattButtonTitle,
|
||||
buttonAction: switchToPowerMode,
|
||||
tapAction: beginCurrentEditing,
|
||||
snapValues: editingField == .current ? nil : currentSnapValues
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
||||
}
|
||||
|
||||
private var headerInfoBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
overviewChip(
|
||||
icon: "powerplug",
|
||||
title: inputBadgeLabel.uppercased(),
|
||||
value: formattedVoltage(configuration.inputVoltage),
|
||||
tint: .indigo
|
||||
)
|
||||
|
||||
overviewChip(
|
||||
icon: "bolt.fill",
|
||||
title: outputBadgeLabel.uppercased(),
|
||||
value: formattedVoltage(configuration.outputVoltage),
|
||||
tint: .green
|
||||
)
|
||||
|
||||
overviewChip(
|
||||
icon: "gauge.medium",
|
||||
title: currentBadgeLabel.uppercased(),
|
||||
value: formattedCurrent(configuration.maxCurrentAmps),
|
||||
tint: .orange
|
||||
)
|
||||
|
||||
overviewChip(
|
||||
icon: "bolt.circle",
|
||||
title: powerBadgeLabel.uppercased(),
|
||||
value: formattedPower(configuration.effectivePowerWatts),
|
||||
tint: .pink
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
.scrollClipDisabled(true)
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private func overviewChip(icon: String, title: String, value: String, tint: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
|
||||
private var displayedPowerValue: Double {
|
||||
if configuration.maxPowerWatts > 0 {
|
||||
return configuration.maxPowerWatts
|
||||
}
|
||||
if lastManualPowerWatts > 0 {
|
||||
return lastManualPowerWatts
|
||||
}
|
||||
return max(0, configuration.outputVoltage * configuration.maxCurrentAmps)
|
||||
}
|
||||
|
||||
private func switchToPowerMode() {
|
||||
if configuration.maxPowerWatts <= 0 {
|
||||
let candidate = lastManualPowerWatts > 0 ? lastManualPowerWatts : configuration.outputVoltage * configuration.maxCurrentAmps
|
||||
let normalized = normalizedPower(for: candidate)
|
||||
configuration.maxPowerWatts = normalized
|
||||
lastManualPowerWatts = normalized
|
||||
} else {
|
||||
lastManualPowerWatts = configuration.maxPowerWatts
|
||||
}
|
||||
powerEntryMode = .power
|
||||
synchronizeCurrentWithPower()
|
||||
}
|
||||
|
||||
private func switchToCurrentMode() {
|
||||
if configuration.maxPowerWatts > 0 {
|
||||
lastManualPowerWatts = configuration.maxPowerWatts
|
||||
}
|
||||
configuration.maxPowerWatts = 0
|
||||
powerEntryMode = .current
|
||||
updatePowerFromCurrent()
|
||||
}
|
||||
|
||||
private func synchronizeCurrentWithPower() {
|
||||
guard powerEntryMode == .power else { return }
|
||||
guard configuration.maxPowerWatts > 0 else {
|
||||
configuration.maxCurrentAmps = 0
|
||||
return
|
||||
}
|
||||
let voltage = max(configuration.outputVoltage, 0.1)
|
||||
let derivedCurrent = configuration.maxPowerWatts / voltage
|
||||
configuration.maxCurrentAmps = roundToTenth(derivedCurrent)
|
||||
}
|
||||
|
||||
private func updatePowerFromCurrent() {
|
||||
let derivedPower = configuration.outputVoltage * configuration.maxCurrentAmps
|
||||
lastManualPowerWatts = normalizedPower(for: derivedPower)
|
||||
}
|
||||
|
||||
private func normalizedInputVoltage(for value: Double) -> Double {
|
||||
let rounded = roundToTenth(value)
|
||||
if let snapped = nearestValue(to: rounded, in: inputVoltageSnapValues, tolerance: inputVoltageSnapTolerance) {
|
||||
return snapped
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
private func normalizedOutputVoltage(for value: Double) -> Double {
|
||||
let rounded = roundToTenth(value)
|
||||
if let snapped = nearestValue(to: rounded, in: outputVoltageSnapValues, tolerance: outputVoltageSnapTolerance) {
|
||||
return snapped
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
private func normalizedCurrent(for value: Double) -> Double {
|
||||
let rounded = roundToTenth(value)
|
||||
if let snapped = nearestValue(to: rounded, in: currentSnapValues, tolerance: currentSnapTolerance) {
|
||||
return snapped
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
private func normalizedPower(for value: Double) -> Double {
|
||||
let rounded = roundToNearestFive(value)
|
||||
if let snapped = nearestValue(to: rounded, in: powerSnapValues, tolerance: powerSnapTolerance) {
|
||||
return snapped
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
private func roundToTenth(_ value: Double) -> Double {
|
||||
max(0, (value * 10).rounded() / 10)
|
||||
}
|
||||
|
||||
private func roundToNearestFive(_ value: Double) -> Double {
|
||||
max(0, (value / 5).rounded() * 5)
|
||||
}
|
||||
|
||||
private func formattedEditValue(_ value: Double) -> String {
|
||||
Self.decimalFormatter.string(from: NSNumber(value: roundToTenth(value))) ?? String(format: "%.1f", value)
|
||||
}
|
||||
|
||||
private func parseInput(_ text: String) -> Double? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if let number = Self.decimalFormatter.number(from: trimmed)?.doubleValue {
|
||||
return number
|
||||
}
|
||||
let decimalSeparator = Locale.current.decimalSeparator ?? "."
|
||||
let altSeparator = decimalSeparator == "." ? "," : "."
|
||||
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
|
||||
return Self.decimalFormatter.number(from: normalized)?.doubleValue
|
||||
}
|
||||
|
||||
private func beginInputVoltageEditing() {
|
||||
inputVoltageInput = formattedEditValue(configuration.inputVoltage)
|
||||
editingField = .inputVoltage
|
||||
}
|
||||
|
||||
private func beginOutputVoltageEditing() {
|
||||
outputVoltageInput = formattedEditValue(configuration.outputVoltage)
|
||||
editingField = .outputVoltage
|
||||
}
|
||||
|
||||
private func beginCurrentEditing() {
|
||||
currentInput = formattedEditValue(configuration.maxCurrentAmps)
|
||||
editingField = .current
|
||||
}
|
||||
|
||||
private func beginPowerEditing() {
|
||||
powerInput = formattedPowerEditValue(displayedPowerValue)
|
||||
editingField = .power
|
||||
}
|
||||
|
||||
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 formattedVoltage(_ value: Double) -> String {
|
||||
let numberString = Self.decimalFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||
return "\(numberString) V"
|
||||
}
|
||||
|
||||
private func formattedCurrent(_ value: Double) -> String {
|
||||
let numberString = Self.decimalFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
|
||||
return "\(numberString) A"
|
||||
}
|
||||
|
||||
private func formattedPower(_ value: Double) -> String {
|
||||
guard value > 0 else { return "— W" }
|
||||
let numberString = Self.powerFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
|
||||
return "\(numberString) W"
|
||||
}
|
||||
|
||||
private func formattedPowerEditValue(_ value: Double) -> String {
|
||||
guard value > 0 else { return "" }
|
||||
return Self.powerFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
|
||||
}
|
||||
|
||||
private static let decimalFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 1
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let powerFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.locale = .current
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 0
|
||||
formatter.maximumFractionDigits = 0
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let previewSystem = ElectricalSystem(name: "Camper")
|
||||
return NavigationStack {
|
||||
ChargerEditorView(
|
||||
configuration: ChargerConfiguration(
|
||||
name: "Workshop Charger",
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40,
|
||||
maxPowerWatts: 580,
|
||||
iconName: "bolt.fill",
|
||||
colorName: "orange",
|
||||
system: previewSystem
|
||||
),
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
379
Cable/Chargers/ChargersView.swift
Normal file
379
Cable/Chargers/ChargersView.swift
Normal file
@@ -0,0 +1,379 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChargersView: View {
|
||||
@Binding var editMode: EditMode
|
||||
let system: ElectricalSystem
|
||||
let chargers: [SavedCharger]
|
||||
let onAdd: () -> Void
|
||||
let onEdit: (SavedCharger) -> Void
|
||||
let onDelete: (IndexSet) -> Void
|
||||
|
||||
private struct SummaryMetric: Identifiable {
|
||||
let id: String
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
let tint: Color
|
||||
}
|
||||
|
||||
private var summaryTitle: String {
|
||||
String(
|
||||
localized: "chargers.summary.title",
|
||||
bundle: .main,
|
||||
comment: "Title for the chargers summary section"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryCountLabel: String {
|
||||
String(
|
||||
localized: "chargers.summary.metric.count",
|
||||
bundle: .main,
|
||||
comment: "Label for number of chargers metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryOutputLabel: String {
|
||||
String(
|
||||
localized: "chargers.summary.metric.output",
|
||||
bundle: .main,
|
||||
comment: "Label for output voltage metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryCurrentLabel: String {
|
||||
String(
|
||||
localized: "chargers.summary.metric.current",
|
||||
bundle: .main,
|
||||
comment: "Label for combined current metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var summaryPowerLabel: String {
|
||||
String(
|
||||
localized: "chargers.summary.metric.power",
|
||||
bundle: .main,
|
||||
comment: "Label for combined power metric"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgeInputLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.input",
|
||||
bundle: .main,
|
||||
comment: "Label for input voltage badge"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgeOutputLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.output",
|
||||
bundle: .main,
|
||||
comment: "Label for output voltage badge"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgeCurrentLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.current",
|
||||
bundle: .main,
|
||||
comment: "Label for charging current badge"
|
||||
)
|
||||
}
|
||||
|
||||
private var badgePowerLabel: String {
|
||||
String(
|
||||
localized: "chargers.badge.power",
|
||||
bundle: .main,
|
||||
comment: "Label for charging power badge"
|
||||
)
|
||||
}
|
||||
|
||||
init(
|
||||
system: ElectricalSystem,
|
||||
chargers: [SavedCharger],
|
||||
editMode: Binding<EditMode> = .constant(.inactive),
|
||||
onAdd: @escaping () -> Void = {},
|
||||
onEdit: @escaping (SavedCharger) -> Void = { _ in },
|
||||
onDelete: @escaping (IndexSet) -> Void = { _ in }
|
||||
) {
|
||||
self.system = system
|
||||
self.chargers = chargers
|
||||
self.onAdd = onAdd
|
||||
self.onEdit = onEdit
|
||||
self.onDelete = onDelete
|
||||
_editMode = editMode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if chargers.isEmpty {
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
summarySection
|
||||
|
||||
List {
|
||||
ForEach(chargers) { charger in
|
||||
Button {
|
||||
onEdit(charger)
|
||||
} label: {
|
||||
chargerRow(for: charger)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(editMode == .active)
|
||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
.onDelete(perform: onDelete)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.environment(\.editMode, $editMode)
|
||||
.accessibilityIdentifier("chargers-list")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(summaryTitle)
|
||||
.font(.headline.weight(.semibold))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 16) {
|
||||
ForEach(summaryMetrics) { metric in
|
||||
ComponentSummaryMetricView(
|
||||
icon: metric.icon,
|
||||
label: metric.label,
|
||||
value: metric.value,
|
||||
tint: metric.tint
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.scrollClipDisabled(true)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
Divider()
|
||||
.background(Color(.separator))
|
||||
.padding(.leading, 0)
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
}
|
||||
|
||||
private var summaryMetrics: [SummaryMetric] {
|
||||
guard !chargers.isEmpty else { return [] }
|
||||
|
||||
var metrics: [SummaryMetric] = [
|
||||
SummaryMetric(
|
||||
id: "count",
|
||||
icon: "bolt.fill",
|
||||
label: summaryCountLabel,
|
||||
value: "\(chargers.count)",
|
||||
tint: .blue
|
||||
)
|
||||
]
|
||||
|
||||
if let output = representativeOutputVoltage {
|
||||
metrics.append(
|
||||
SummaryMetric(
|
||||
id: "output",
|
||||
icon: "battery.100.bolt",
|
||||
label: summaryOutputLabel,
|
||||
value: formattedVoltage(output),
|
||||
tint: .green
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if totalCurrent > 0 {
|
||||
metrics.append(
|
||||
SummaryMetric(
|
||||
id: "current",
|
||||
icon: "gauge",
|
||||
label: summaryCurrentLabel,
|
||||
value: formattedCurrent(totalCurrent),
|
||||
tint: .orange
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if totalPower > 0 {
|
||||
metrics.append(
|
||||
SummaryMetric(
|
||||
id: "power",
|
||||
icon: "bolt.badge.a",
|
||||
label: summaryPowerLabel,
|
||||
value: formattedPower(totalPower),
|
||||
tint: .pink
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
OnboardingInfoView(
|
||||
configuration: .charger(),
|
||||
onPrimaryAction: onAdd
|
||||
)
|
||||
.padding(.horizontal, 0)
|
||||
}
|
||||
|
||||
private func chargerRow(for charger: SavedCharger) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 12) {
|
||||
LoadIconView(
|
||||
remoteIconURLString: charger.remoteIconURLString,
|
||||
fallbackSystemName: charger.iconName,
|
||||
fallbackColor: Color.componentColor(named: charger.colorName),
|
||||
size: 48
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(charger.name)
|
||||
.font(.body.weight(.medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(chargerSummary(for: charger))
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule(style: .continuous)
|
||||
.fill(Color(.tertiarySystemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if editMode == .inactive {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
metricsSection(for: charger)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metricsSection(for charger: SavedCharger) -> some View {
|
||||
let badges: [(String, String, Color)] = [
|
||||
(badgeInputLabel, formattedVoltage(charger.inputVoltage), .indigo),
|
||||
(badgeOutputLabel, formattedVoltage(charger.outputVoltage), .green),
|
||||
(badgeCurrentLabel, formattedCurrent(charger.maxCurrentAmps), .orange),
|
||||
(badgePowerLabel, formattedPower(charger.effectivePowerWatts), .pink)
|
||||
]
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(badges, id: \.0) { label, value, tint in
|
||||
ComponentMetricBadgeView(label: label, value: value, tint: tint)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 16)
|
||||
}
|
||||
.scrollClipDisabled(true)
|
||||
}
|
||||
|
||||
private func chargerSummary(for charger: SavedCharger) -> String {
|
||||
let inputText = formattedVoltage(charger.inputVoltage)
|
||||
let outputText = formattedVoltage(charger.outputVoltage)
|
||||
let currentText = formattedCurrent(charger.maxCurrentAmps)
|
||||
return [inputText, outputText, currentText].joined(separator: " • ")
|
||||
}
|
||||
|
||||
private var totalCurrent: Double {
|
||||
chargers.reduce(0) { result, charger in
|
||||
result + max(0, charger.maxCurrentAmps)
|
||||
}
|
||||
}
|
||||
|
||||
private var totalPower: Double {
|
||||
chargers.reduce(0) { result, charger in
|
||||
result + max(0, charger.effectivePowerWatts)
|
||||
}
|
||||
}
|
||||
|
||||
private var representativeOutputVoltage: Double? {
|
||||
let outputs = chargers.map { $0.outputVoltage }.filter { $0 > 0 }
|
||||
guard !outputs.isEmpty else { return nil }
|
||||
let total = outputs.reduce(0, +)
|
||||
return total / Double(outputs.count)
|
||||
}
|
||||
|
||||
private func formattedVoltage(_ value: Double) -> String {
|
||||
guard value > 0 else { return "—" }
|
||||
return String(format: "%.1fV", value)
|
||||
}
|
||||
|
||||
private func formattedCurrent(_ value: Double) -> String {
|
||||
guard value > 0 else { return "—" }
|
||||
return String(format: "%.1fA", value)
|
||||
}
|
||||
|
||||
private func formattedPower(_ value: Double) -> String {
|
||||
guard value > 0 else { return "—" }
|
||||
return String(format: "%.0fW", value)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ChargersViewPreviewData {
|
||||
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "teal")
|
||||
static let chargers: [SavedCharger] = {
|
||||
let shore = SavedCharger(
|
||||
name: "Shore Power",
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40,
|
||||
maxPowerWatts: 580,
|
||||
iconName: "powerplug",
|
||||
colorName: "orange",
|
||||
system: system
|
||||
)
|
||||
shore.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
|
||||
|
||||
let dcDc = SavedCharger(
|
||||
name: "DC-DC Charger",
|
||||
inputVoltage: 12.8,
|
||||
outputVoltage: 14.2,
|
||||
maxCurrentAmps: 30,
|
||||
maxPowerWatts: 0,
|
||||
iconName: "bolt.badge.clock",
|
||||
colorName: "blue",
|
||||
system: system
|
||||
)
|
||||
dcDc.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
|
||||
|
||||
return [shore, dcDc]
|
||||
}()
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChargersView(
|
||||
system: ChargersViewPreviewData.system,
|
||||
chargers: ChargersViewPreviewData.chargers,
|
||||
editMode: .constant(.inactive)
|
||||
)
|
||||
}
|
||||
59
Cable/Chargers/SavedCharger.swift
Normal file
59
Cable/Chargers/SavedCharger.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class SavedCharger {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var inputVoltage: Double
|
||||
var outputVoltage: Double
|
||||
var maxCurrentAmps: Double
|
||||
var maxPowerWatts: Double
|
||||
var iconName: String
|
||||
var colorName: String
|
||||
var system: ElectricalSystem?
|
||||
var timestamp: Date
|
||||
var remoteIconURLString: String?
|
||||
var affiliateURLString: String?
|
||||
var affiliateCountryCode: String?
|
||||
var identifier: String
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
inputVoltage: Double = 230.0,
|
||||
outputVoltage: Double = 14.2,
|
||||
maxCurrentAmps: Double = 30.0,
|
||||
maxPowerWatts: Double = 0.0,
|
||||
iconName: String = "bolt.fill",
|
||||
colorName: String = "orange",
|
||||
system: ElectricalSystem? = nil,
|
||||
timestamp: Date = Date(),
|
||||
remoteIconURLString: String? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
identifier: String = UUID().uuidString
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.inputVoltage = inputVoltage
|
||||
self.outputVoltage = outputVoltage
|
||||
self.maxCurrentAmps = maxCurrentAmps
|
||||
self.maxPowerWatts = maxPowerWatts
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.timestamp = timestamp
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.identifier = identifier
|
||||
}
|
||||
|
||||
var effectivePowerWatts: Double {
|
||||
if maxPowerWatts > 0 {
|
||||
return maxPowerWatts
|
||||
}
|
||||
return outputVoltage * maxCurrentAmps
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ComponentsOnboardingView: View {
|
||||
@State private var carouselStep = 0
|
||||
let onCreate: () -> Void
|
||||
let onBrowse: () -> Void
|
||||
|
||||
private let imageNames = [
|
||||
"coffee-onboarding",
|
||||
"router-onboarding",
|
||||
"charger-onboarding"
|
||||
]
|
||||
|
||||
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
|
||||
private let animationDuration = 0.8
|
||||
|
||||
private var loopingImages: [String] {
|
||||
guard let first = imageNames.first else { return [] }
|
||||
return imageNames + [first]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer(minLength: 32)
|
||||
|
||||
OnboardingCarouselView(images: loopingImages, step: carouselStep)
|
||||
.frame(minHeight: 80, maxHeight: 240)
|
||||
.padding(.horizontal, 0)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Add your first component")
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(minHeight: 72)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button(action: createComponent) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Create Component")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.accessibilityIdentifier("create-component-button")
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onBrowse) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "books.vertical")
|
||||
.font(.system(size: 16))
|
||||
Text("Browse Library")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.blue.opacity(0.12))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("select-component-button")
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear(perform: resetState)
|
||||
.onReceive(timer) { _ in advanceCarousel() }
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
carouselStep = 0
|
||||
}
|
||||
|
||||
private func createComponent() {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
private func advanceCarousel() {
|
||||
guard imageNames.count > 1 else { return }
|
||||
let next = carouselStep + 1
|
||||
|
||||
withAnimation(.easeInOut(duration: animationDuration)) {
|
||||
carouselStep = next
|
||||
}
|
||||
|
||||
if next == imageNames.count {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
|
||||
withAnimation(.none) {
|
||||
carouselStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ComponentsOnboardingView(onCreate: {}, onBrowse: {})
|
||||
}
|
||||
@@ -151,10 +151,10 @@ struct CalculatorView: View {
|
||||
lengthInput = formattedValue(calculator.length)
|
||||
}
|
||||
}
|
||||
.onChange(of: lengthInput) { newValue in
|
||||
guard editingValue == .length, let parsed = parseInput(newValue) else { return }
|
||||
calculator.length = roundToTenth(parsed)
|
||||
}
|
||||
.onChange(of: lengthInput) { _, newValue in
|
||||
guard editingValue == .length, let parsed = parseInput(newValue) else { return }
|
||||
calculator.length = roundToTenth(parsed)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
editingValue = nil
|
||||
lengthInput = ""
|
||||
@@ -187,10 +187,10 @@ struct CalculatorView: View {
|
||||
voltageInput = formattedValue(calculator.voltage)
|
||||
}
|
||||
}
|
||||
.onChange(of: voltageInput) { newValue in
|
||||
guard editingValue == .voltage, let parsed = parseInput(newValue) else { return }
|
||||
calculator.voltage = roundToTenth(parsed)
|
||||
}
|
||||
.onChange(of: voltageInput) { _, newValue in
|
||||
guard editingValue == .voltage, let parsed = parseInput(newValue) else { return }
|
||||
calculator.voltage = roundToTenth(parsed)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
editingValue = nil
|
||||
voltageInput = ""
|
||||
@@ -228,10 +228,10 @@ struct CalculatorView: View {
|
||||
currentInput = formattedValue(calculator.current)
|
||||
}
|
||||
}
|
||||
.onChange(of: currentInput) { newValue in
|
||||
guard editingValue == .current, let parsed = parseInput(newValue) else { return }
|
||||
calculator.current = roundToTenth(parsed)
|
||||
}
|
||||
.onChange(of: currentInput) { _, newValue in
|
||||
guard editingValue == .current, let parsed = parseInput(newValue) else { return }
|
||||
calculator.current = roundToTenth(parsed)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
editingValue = nil
|
||||
currentInput = ""
|
||||
@@ -265,10 +265,10 @@ struct CalculatorView: View {
|
||||
powerInput = formattedValue(calculator.power)
|
||||
}
|
||||
}
|
||||
.onChange(of: powerInput) { newValue in
|
||||
guard editingValue == .power, let parsed = parseInput(newValue) else { return }
|
||||
calculator.power = roundToNearestFive(parsed)
|
||||
}
|
||||
.onChange(of: powerInput) { _, newValue in
|
||||
guard editingValue == .power, let parsed = parseInput(newValue) else { return }
|
||||
calculator.power = roundToNearestFive(parsed)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
editingValue = nil
|
||||
powerInput = ""
|
||||
@@ -824,7 +824,7 @@ struct CalculatorView: View {
|
||||
unit: "V",
|
||||
tapAction: beginVoltageEditing,
|
||||
snapValues: editingValue == .voltage ? nil : voltageSnapValues)
|
||||
.onChange(of: calculator.voltage) {
|
||||
.onChange(of: calculator.voltage) { _, _ in
|
||||
if isWattMode {
|
||||
calculator.updateFromPower()
|
||||
} else {
|
||||
@@ -859,7 +859,7 @@ struct CalculatorView: View {
|
||||
},
|
||||
tapAction: beginPowerEditing,
|
||||
snapValues: editingValue == .power ? nil : powerSnapValues)
|
||||
.onChange(of: calculator.power) {
|
||||
.onChange(of: calculator.power) { _, _ in
|
||||
calculator.updateFromPower()
|
||||
calculator.objectWillChange.send()
|
||||
autoUpdateSavedLoad()
|
||||
@@ -886,7 +886,7 @@ struct CalculatorView: View {
|
||||
},
|
||||
tapAction: beginCurrentEditing,
|
||||
snapValues: editingValue == .current ? nil : currentSnapValues)
|
||||
.onChange(of: calculator.current) {
|
||||
.onChange(of: calculator.current) { _, _ in
|
||||
calculator.updateFromCurrent()
|
||||
calculator.objectWillChange.send()
|
||||
autoUpdateSavedLoad()
|
||||
@@ -914,10 +914,10 @@ struct CalculatorView: View {
|
||||
unit: unitSettings.unitSystem.lengthUnit,
|
||||
tapAction: beginLengthEditing,
|
||||
snapValues: editingValue == .length ? nil : lengthSnapValues)
|
||||
.onChange(of: calculator.length) {
|
||||
calculator.objectWillChange.send()
|
||||
autoUpdateSavedLoad()
|
||||
}
|
||||
.onChange(of: calculator.length) { _, _ in
|
||||
calculator.objectWillChange.send()
|
||||
autoUpdateSavedLoad()
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedVoltage(for value: Double) -> Double {
|
||||
@@ -1239,17 +1239,21 @@ struct SliderSection: View {
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Slider(value: $value, in: range)
|
||||
.onChange(of: value) {
|
||||
.onChange(of: value) { _, newValue in
|
||||
// Always round to 1 decimal place first
|
||||
value = round(value * 10) / 10
|
||||
|
||||
var adjusted = (newValue * 10).rounded() / 10
|
||||
|
||||
if let snapValues = snapValues {
|
||||
// Find the closest snap value
|
||||
let closest = snapValues.min { abs($0 - value) < abs($1 - value) }
|
||||
if let closest = closest, abs(closest - value) < 0.5 {
|
||||
value = closest
|
||||
if let closest = snapValues.min(by: { abs($0 - adjusted) < abs($1 - adjusted) }),
|
||||
abs(closest - adjusted) < 0.5 {
|
||||
adjusted = closest
|
||||
}
|
||||
}
|
||||
|
||||
if abs(adjusted - newValue) > 0.000001 {
|
||||
value = adjusted
|
||||
}
|
||||
}
|
||||
|
||||
Text(String(format: "%.0f%@", range.upperBound, unit))
|
||||
@@ -126,15 +126,6 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
|
||||
append(locale.identifier)
|
||||
|
||||
let components = Locale.components(fromIdentifier: locale.identifier)
|
||||
|
||||
if let language = components[NSLocale.Key.languageCode.rawValue]?.lowercased() {
|
||||
if let region = components[NSLocale.Key.countryCode.rawValue]?.uppercased() {
|
||||
append("\(language)_\(region)")
|
||||
}
|
||||
append(language)
|
||||
}
|
||||
|
||||
if let languageCode = locale.language.languageCode?.identifier.lowercased() {
|
||||
append(languageCode)
|
||||
}
|
||||
@@ -75,3 +75,77 @@ struct LoadIconView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentSummaryMetricView: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
let tint: Color
|
||||
|
||||
var body: 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))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentMetricBadgeView: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static func componentColor(named 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ struct LoadsView: View {
|
||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
|
||||
@Query(sort: \SavedBattery.timestamp, order: .reverse) private var allBatteries: [SavedBattery]
|
||||
@Query(sort: \SavedCharger.timestamp, order: .reverse) private var allChargers: [SavedCharger]
|
||||
@State private var newLoadToEdit: SavedLoad?
|
||||
@State private var showingSystemEditor = false
|
||||
@State private var hasPresentedSystemEditorOnAppear = false
|
||||
@@ -22,6 +23,7 @@ struct LoadsView: View {
|
||||
@State private var showingSystemBOM = false
|
||||
@State private var selectedComponentTab: ComponentTab = .overview
|
||||
@State private var batteryDraft: BatteryConfiguration?
|
||||
@State private var chargerDraft: ChargerConfiguration?
|
||||
@State private var activeStatus: LoadConfigurationStatus?
|
||||
@State private var editMode: EditMode = .inactive
|
||||
|
||||
@@ -43,6 +45,10 @@ struct LoadsView: View {
|
||||
allBatteries.filter { $0.system == system }
|
||||
}
|
||||
|
||||
private var savedChargers: [SavedCharger] {
|
||||
allChargers.filter { $0.system == system }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $selectedComponentTab) {
|
||||
@@ -58,6 +64,7 @@ struct LoadsView: View {
|
||||
systemImage: "rectangle.3.group"
|
||||
)
|
||||
}
|
||||
|
||||
componentsTab
|
||||
.tag(ComponentTab.components)
|
||||
.tabItem {
|
||||
@@ -70,37 +77,57 @@ struct LoadsView: View {
|
||||
systemImage: "square.stack.3d.up"
|
||||
)
|
||||
}
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
editMode: $editMode,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
|
||||
Group {
|
||||
if savedBatteries.isEmpty {
|
||||
OnboardingInfoView(
|
||||
configuration: .battery(),
|
||||
onPrimaryAction: { startBatteryConfiguration() }
|
||||
)
|
||||
} else {
|
||||
BatteriesView(
|
||||
system: system,
|
||||
batteries: savedBatteries,
|
||||
editMode: $editMode,
|
||||
onEdit: { editBattery($0) },
|
||||
onDelete: deleteBatteries
|
||||
)
|
||||
.environment(\.editMode, $editMode)
|
||||
}
|
||||
}
|
||||
.tag(ComponentTab.batteries)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.batteries",
|
||||
bundle: .main,
|
||||
comment: "Tab title for battery configurations"
|
||||
),
|
||||
systemImage: "battery.100"
|
||||
)
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
|
||||
ChargersView(
|
||||
system: system,
|
||||
chargers: savedChargers,
|
||||
editMode: $editMode,
|
||||
onAdd: { startChargerConfiguration() },
|
||||
onEdit: { editCharger($0) },
|
||||
onDelete: deleteChargers
|
||||
)
|
||||
.tag(ComponentTab.chargers)
|
||||
.tabItem {
|
||||
Label(
|
||||
String(
|
||||
localized: "tab.chargers",
|
||||
bundle: .main,
|
||||
comment: "Tab title for chargers view"
|
||||
),
|
||||
systemImage: "bolt.fill"
|
||||
)
|
||||
}
|
||||
.environment(\.editMode, $editMode)
|
||||
.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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -112,7 +139,7 @@ struct LoadsView: View {
|
||||
HStack(spacing: 8) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(colorForName(system.colorName))
|
||||
.fill(Color.componentColor(named: system.colorName))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Image(systemName: system.iconName)
|
||||
@@ -129,35 +156,30 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
if selectedComponentTab == .components {
|
||||
Button(action: {
|
||||
showingComponentLibrary = true
|
||||
}) {
|
||||
Image(systemName: "books.vertical")
|
||||
let showPrimary = selectedComponentTab != .overview
|
||||
let showEditLoads = selectedComponentTab == .components && !savedLoads.isEmpty
|
||||
let showEditBatteries = selectedComponentTab == .batteries && !savedBatteries.isEmpty
|
||||
let showEditChargers = selectedComponentTab == .chargers && !savedChargers.isEmpty
|
||||
|
||||
if showPrimary || showEditLoads || showEditBatteries || showEditChargers {
|
||||
HStack {
|
||||
if showPrimary {
|
||||
Button(action: {
|
||||
handlePrimaryAction()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("component-library-button")
|
||||
}
|
||||
if !savedLoads.isEmpty && (selectedComponentTab == .components || selectedComponentTab == .overview) {
|
||||
Button(action: {
|
||||
showingSystemBOM = true
|
||||
}) {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
if showEditLoads {
|
||||
EditButton()
|
||||
.disabled(savedLoads.isEmpty)
|
||||
} else if showEditBatteries {
|
||||
EditButton()
|
||||
.disabled(savedBatteries.isEmpty)
|
||||
} else if showEditChargers {
|
||||
EditButton()
|
||||
.disabled(savedChargers.isEmpty)
|
||||
}
|
||||
.accessibilityIdentifier("system-bom-button")
|
||||
}
|
||||
Button(action: {
|
||||
handlePrimaryAction()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.disabled(selectedComponentTab == .chargers)
|
||||
if selectedComponentTab == .components {
|
||||
EditButton()
|
||||
.disabled(savedLoads.isEmpty)
|
||||
} else if selectedComponentTab == .batteries {
|
||||
EditButton()
|
||||
.disabled(savedBatteries.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +196,15 @@ struct LoadsView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationDestination(item: $chargerDraft) { draft in
|
||||
ChargerEditorView(
|
||||
configuration: draft,
|
||||
onSave: { configuration in
|
||||
saveCharger(configuration)
|
||||
chargerDraft = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
addComponent(item)
|
||||
@@ -252,13 +283,16 @@ struct LoadsView: View {
|
||||
loads: savedLoads,
|
||||
batteries: savedBatteries,
|
||||
onSelectLoads: { selectedComponentTab = .components },
|
||||
onSelectBatteries: { selectedComponentTab = .batteries }
|
||||
onSelectBatteries: { selectedComponentTab = .batteries },
|
||||
onCreateLoad: { createNewLoad() },
|
||||
onBrowseLibrary: { showingComponentLibrary = true },
|
||||
onCreateBattery: { startBatteryConfiguration() }
|
||||
)
|
||||
.accessibilityIdentifier("system-overview")
|
||||
}
|
||||
|
||||
private var summarySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(loadsSummaryTitle)
|
||||
@@ -325,24 +359,47 @@ struct LoadsView: View {
|
||||
|
||||
Divider()
|
||||
.background(Color(.separator))
|
||||
|
||||
libraryButton
|
||||
.padding(.trailing, 16)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private var libraryButton: some View {
|
||||
Button {
|
||||
showingComponentLibrary = true
|
||||
} label: {
|
||||
Label(
|
||||
String(
|
||||
localized: "loads.library.button",
|
||||
bundle: .main,
|
||||
comment: "Button title to open component library"
|
||||
),
|
||||
systemImage: "books.vertical"
|
||||
)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.tint(.accentColor)
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
|
||||
}
|
||||
|
||||
private var componentsTab: some View {
|
||||
VStack(spacing: 0) {
|
||||
summarySection
|
||||
|
||||
if savedLoads.isEmpty {
|
||||
ScrollView {
|
||||
ComponentsOnboardingView(
|
||||
onCreate: { createNewLoad() },
|
||||
onBrowse: { showingComponentLibrary = true }
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 32)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
OnboardingInfoView(
|
||||
configuration: .loads(),
|
||||
onPrimaryAction: { createNewLoad() },
|
||||
onSecondaryAction: { showingComponentLibrary = true }
|
||||
)
|
||||
.padding(.horizontal, 0)
|
||||
} else {
|
||||
summarySection
|
||||
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
Button {
|
||||
@@ -377,7 +434,7 @@ struct LoadsView: View {
|
||||
LoadIconView(
|
||||
remoteIconURLString: load.remoteIconURLString,
|
||||
fallbackSystemName: load.iconName,
|
||||
fallbackColor: colorForName(load.colorName),
|
||||
fallbackColor: Color.componentColor(named: load.colorName),
|
||||
size: 48
|
||||
)
|
||||
|
||||
@@ -586,19 +643,12 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
ComponentSummaryMetricView(
|
||||
icon: icon,
|
||||
label: label,
|
||||
value: value,
|
||||
tint: tint
|
||||
)
|
||||
}
|
||||
|
||||
private func statusBanner(for status: LoadConfigurationStatus) -> some View {
|
||||
@@ -638,20 +688,10 @@ struct LoadsView: View {
|
||||
}()
|
||||
|
||||
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))
|
||||
ComponentMetricBadgeView(
|
||||
label: label,
|
||||
value: value,
|
||||
tint: tint
|
||||
)
|
||||
}
|
||||
|
||||
@@ -672,7 +712,7 @@ struct LoadsView: View {
|
||||
case .batteries:
|
||||
startBatteryConfiguration()
|
||||
case .chargers:
|
||||
break
|
||||
startChargerConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +721,8 @@ struct LoadsView: View {
|
||||
for: system,
|
||||
in: modelContext,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries
|
||||
existingBatteries: savedBatteries,
|
||||
existingChargers: savedChargers
|
||||
)
|
||||
newLoadToEdit = newLoad
|
||||
}
|
||||
@@ -690,7 +731,8 @@ struct LoadsView: View {
|
||||
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
||||
for: system,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries
|
||||
existingBatteries: savedBatteries,
|
||||
existingChargers: savedChargers
|
||||
)
|
||||
}
|
||||
|
||||
@@ -717,36 +759,50 @@ struct LoadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func startChargerConfiguration() {
|
||||
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
|
||||
for: system,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries,
|
||||
existingChargers: savedChargers
|
||||
)
|
||||
}
|
||||
|
||||
private func saveCharger(_ configuration: ChargerConfiguration) {
|
||||
SystemComponentsPersistence.saveCharger(
|
||||
configuration,
|
||||
for: system,
|
||||
existingChargers: savedChargers,
|
||||
in: modelContext
|
||||
)
|
||||
}
|
||||
|
||||
private func editCharger(_ charger: SavedCharger) {
|
||||
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
|
||||
}
|
||||
|
||||
private func deleteChargers(_ offsets: IndexSet) {
|
||||
withAnimation {
|
||||
SystemComponentsPersistence.deleteChargers(
|
||||
at: offsets,
|
||||
from: savedChargers,
|
||||
in: modelContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addComponent(_ item: ComponentLibraryItem) {
|
||||
let newLoad = SystemComponentsPersistence.createLoad(
|
||||
from: item,
|
||||
for: system,
|
||||
in: modelContext,
|
||||
existingLoads: savedLoads,
|
||||
existingBatteries: savedBatteries
|
||||
existingBatteries: savedBatteries,
|
||||
existingChargers: savedChargers
|
||||
)
|
||||
newLoadToEdit = newLoad
|
||||
}
|
||||
|
||||
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 func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||
let awgSizes = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31),
|
||||
(10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
|
||||
161
Cable/Loads/OnboardingInfoView.swift
Normal file
161
Cable/Loads/OnboardingInfoView.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingInfoView: View {
|
||||
struct Configuration {
|
||||
let title: LocalizedStringKey
|
||||
let subtitle: LocalizedStringKey
|
||||
let primaryActionTitle: LocalizedStringKey
|
||||
let primaryActionIcon: String
|
||||
let secondaryActionTitle: LocalizedStringKey?
|
||||
let secondaryActionIcon: String?
|
||||
let imageNames: [String]
|
||||
}
|
||||
|
||||
@State private var carouselStep = 0
|
||||
private let configuration: Configuration
|
||||
private let onPrimaryAction: () -> Void
|
||||
private let onSecondaryAction: () -> Void
|
||||
|
||||
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
|
||||
private let animationDuration = 0.8
|
||||
|
||||
private var loopingImages: [String] {
|
||||
guard let first = configuration.imageNames.first else { return [] }
|
||||
return configuration.imageNames + [first]
|
||||
}
|
||||
|
||||
init(configuration: Configuration, onPrimaryAction: @escaping () -> Void, onSecondaryAction: @escaping () -> Void = {}) {
|
||||
self.configuration = configuration
|
||||
self.onPrimaryAction = onPrimaryAction
|
||||
self.onSecondaryAction = onSecondaryAction
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Spacer(minLength: 32)
|
||||
|
||||
if !loopingImages.isEmpty {
|
||||
OnboardingCarouselView(images: loopingImages, step: carouselStep)
|
||||
.frame(minHeight: 80, maxHeight: 220)
|
||||
.padding(.horizontal, 0)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text(configuration.title)
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(configuration.subtitle)
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(minHeight: 72)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button(action: onPrimaryAction) {
|
||||
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
if let secondaryTitle = configuration.secondaryActionTitle,
|
||||
let secondaryIcon = configuration.secondaryActionIcon {
|
||||
Button(action: onSecondaryAction) {
|
||||
Label(secondaryTitle, systemImage: secondaryIcon)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.accentColor)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.vertical, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear(perform: resetState)
|
||||
.onReceive(timer) { _ in advanceCarousel() }
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
carouselStep = 0
|
||||
}
|
||||
|
||||
private func advanceCarousel() {
|
||||
guard configuration.imageNames.count > 1 else { return }
|
||||
let next = carouselStep + 1
|
||||
|
||||
withAnimation(.easeInOut(duration: animationDuration)) {
|
||||
carouselStep = next
|
||||
}
|
||||
|
||||
if next == configuration.imageNames.count {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
|
||||
withAnimation(.none) {
|
||||
carouselStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingInfoView(
|
||||
configuration: .loads(),
|
||||
onPrimaryAction: {},
|
||||
onSecondaryAction: {}
|
||||
)
|
||||
}
|
||||
|
||||
extension OnboardingInfoView.Configuration {
|
||||
static func loads() -> Self {
|
||||
Self(
|
||||
title: LocalizedStringKey("loads.onboarding.title"),
|
||||
subtitle: LocalizedStringKey("loads.onboarding.subtitle"),
|
||||
primaryActionTitle: LocalizedStringKey("loads.overview.empty.create"),
|
||||
primaryActionIcon: "plus",
|
||||
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
|
||||
secondaryActionIcon: "books.vertical",
|
||||
imageNames: [
|
||||
"coffee-onboarding",
|
||||
"router-onboarding",
|
||||
"charger-onboarding"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func battery() -> Self {
|
||||
Self(
|
||||
title: LocalizedStringKey("battery.onboarding.title"),
|
||||
subtitle: LocalizedStringKey("battery.onboarding.subtitle"),
|
||||
primaryActionTitle: LocalizedStringKey("battery.overview.empty.create"),
|
||||
primaryActionIcon: "plus",
|
||||
secondaryActionTitle: nil,
|
||||
secondaryActionIcon: nil,
|
||||
imageNames: [
|
||||
"battery-onboarding"
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func charger() -> Self {
|
||||
Self(
|
||||
title: LocalizedStringKey("chargers.onboarding.title"),
|
||||
subtitle: LocalizedStringKey("chargers.onboarding.subtitle"),
|
||||
primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"),
|
||||
primaryActionIcon: "plus",
|
||||
secondaryActionTitle: nil,
|
||||
secondaryActionIcon: nil,
|
||||
imageNames: [
|
||||
"charger-onboarding"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SystemOverviewView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var activeStatus: LoadConfigurationStatus?
|
||||
@State private var suppressLoadNavigation = false
|
||||
let system: ElectricalSystem
|
||||
@@ -8,6 +9,9 @@ struct SystemOverviewView: View {
|
||||
let batteries: [SavedBattery]
|
||||
let onSelectLoads: () -> Void
|
||||
let onSelectBatteries: () -> Void
|
||||
let onCreateLoad: () -> Void
|
||||
let onBrowseLibrary: () -> Void
|
||||
let onCreateBattery: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -30,7 +34,7 @@ struct SystemOverviewView: View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(colorForName(system.colorName))
|
||||
.fill(Color.componentColor(named: system.colorName))
|
||||
.frame(width: 54, height: 54)
|
||||
Image(systemName: system.iconName)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
@@ -95,14 +99,9 @@ struct SystemOverviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadsCard: some View {
|
||||
Button {
|
||||
if suppressLoadNavigation {
|
||||
suppressLoadNavigation = false
|
||||
return
|
||||
}
|
||||
onSelectLoads()
|
||||
} label: {
|
||||
if loads.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(loadsSummaryTitle)
|
||||
@@ -110,15 +109,53 @@ struct SystemOverviewView: View {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if loads.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(loadsEmptyTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(loadsEmptySubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(loadsEmptyTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(loadsEmptyMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Button(action: onCreateLoad) {
|
||||
Label(loadsEmptyCreateAction, systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} else {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button(action: onBrowseLibrary) {
|
||||
Label(loadsEmptyBrowseAction, systemImage: "books.vertical")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(.accentColor)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
} else {
|
||||
Button {
|
||||
if suppressLoadNavigation {
|
||||
suppressLoadNavigation = false
|
||||
return
|
||||
}
|
||||
onSelectLoads()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(loadsSummaryTitle)
|
||||
.font(.headline.weight(.semibold))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
summaryMetric(
|
||||
@@ -173,32 +210,32 @@ struct SystemOverviewView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.alert(item: $activeStatus) { status in
|
||||
let detail = status.detailInfo()
|
||||
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 load status alert"
|
||||
.buttonStyle(.plain)
|
||||
.alert(item: $activeStatus) { status in
|
||||
let detail = status.detailInfo()
|
||||
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 load status alert"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,78 +247,87 @@ struct SystemOverviewView: View {
|
||||
.font(.headline.weight(.semibold))
|
||||
if let warning = batteryWarning {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: warning.symbol)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(warning.tint)
|
||||
Text(warning.shortLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(warning.tint)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(warning.tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if batteries.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(batteryEmptyTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(batteryEmptySubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
} else {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: batteryCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .blue
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "gauge.medium",
|
||||
label: batteryCapacityLabel,
|
||||
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||
tint: .orange
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "bolt.circle",
|
||||
label: batteryEnergyLabel,
|
||||
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||
tint: .green
|
||||
Image(systemName: warning.symbol)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(warning.tint)
|
||||
Text(warning.shortLabel)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(warning.tint)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(warning.tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if batteries.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: batteryCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .blue
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "gauge.medium",
|
||||
label: batteryCapacityLabel,
|
||||
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||
tint: .orange
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "bolt.circle",
|
||||
label: batteryEnergyLabel,
|
||||
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||
tint: .green
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(batteryEmptyTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(batteryEmptySubtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Button(action: onCreateBattery) {
|
||||
Label(batteryEmptyCreateAction, systemImage: "plus")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
} else {
|
||||
ViewThatFits(in: .horizontal) {
|
||||
HStack(spacing: 16) {
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: batteryCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .blue
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "gauge.medium",
|
||||
label: batteryCapacityLabel,
|
||||
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||
tint: .orange
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "bolt.circle",
|
||||
label: batteryEnergyLabel,
|
||||
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||
tint: .green
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
summaryMetric(
|
||||
icon: "battery.100",
|
||||
label: batteryCountLabel,
|
||||
value: "\(batteries.count)",
|
||||
tint: .blue
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "gauge.medium",
|
||||
label: batteryCapacityLabel,
|
||||
value: formattedValue(totalCapacity, unit: "Ah"),
|
||||
tint: .orange
|
||||
)
|
||||
summaryMetric(
|
||||
icon: "bolt.circle",
|
||||
label: batteryEnergyLabel,
|
||||
value: formattedValue(totalEnergy, unit: "Wh"),
|
||||
tint: .green
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -467,25 +513,6 @@ struct SystemOverviewView: View {
|
||||
!batteries.isEmpty && !loads.isEmpty && formattedRuntime == nil
|
||||
}
|
||||
|
||||
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 loadsSummaryTitle: String {
|
||||
NSLocalizedString(
|
||||
"loads.overview.header.title",
|
||||
@@ -540,6 +567,33 @@ struct SystemOverviewView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var loadsEmptyMessage: String {
|
||||
NSLocalizedString(
|
||||
"loads.overview.empty.message",
|
||||
bundle: .main,
|
||||
value: "Start by adding a load to see system insights.",
|
||||
comment: "Message shown when no loads exist"
|
||||
)
|
||||
}
|
||||
|
||||
private var loadsEmptyCreateAction: String {
|
||||
NSLocalizedString(
|
||||
"loads.overview.empty.create",
|
||||
bundle: .main,
|
||||
value: "Create Load",
|
||||
comment: "Button title to create a new load"
|
||||
)
|
||||
}
|
||||
|
||||
private var loadsEmptyBrowseAction: String {
|
||||
NSLocalizedString(
|
||||
"loads.overview.empty.library",
|
||||
bundle: .main,
|
||||
value: "Browse Library",
|
||||
comment: "Button title to open load library"
|
||||
)
|
||||
}
|
||||
|
||||
private var batterySummaryTitle: String {
|
||||
NSLocalizedString(
|
||||
"battery.bank.header.title",
|
||||
@@ -596,6 +650,15 @@ struct SystemOverviewView: View {
|
||||
return String(format: format, system.name)
|
||||
}
|
||||
|
||||
private var batteryEmptyCreateAction: String {
|
||||
NSLocalizedString(
|
||||
"battery.overview.empty.create",
|
||||
bundle: .main,
|
||||
value: "Create Battery",
|
||||
comment: "Button title to create a new battery"
|
||||
)
|
||||
}
|
||||
|
||||
private var systemOverviewTitle: String {
|
||||
NSLocalizedString(
|
||||
"overview.system.header.title",
|
||||
@@ -690,3 +753,85 @@ struct SystemOverviewView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("SystemOverview – Populated") {
|
||||
let system = ElectricalSystem(
|
||||
name: "12V DC System",
|
||||
location: "Engine Room",
|
||||
iconName: "bolt.circle.fill",
|
||||
colorName: "blue"
|
||||
)
|
||||
|
||||
let loads: [SavedLoad] = [
|
||||
SavedLoad(
|
||||
name: "Navigation Lights",
|
||||
voltage: 12.8,
|
||||
current: 2.4,
|
||||
power: 28.8,
|
||||
length: 5.0,
|
||||
crossSection: 2.5
|
||||
),
|
||||
SavedLoad(
|
||||
name: "Bilge Pump",
|
||||
voltage: 12.8,
|
||||
current: 8.0,
|
||||
power: 96.0,
|
||||
length: 3.0,
|
||||
crossSection: 4.0
|
||||
),
|
||||
SavedLoad(
|
||||
name: "Chartplotter",
|
||||
voltage: 12.8,
|
||||
current: 1.5,
|
||||
power: 18.0,
|
||||
length: 2.0,
|
||||
crossSection: 1.5
|
||||
)
|
||||
]
|
||||
|
||||
let batteries: [SavedBattery] = [
|
||||
SavedBattery(
|
||||
name: "House AGM",
|
||||
nominalVoltage: 12.0,
|
||||
capacityAmpHours: 100.0
|
||||
),
|
||||
SavedBattery(
|
||||
name: "Starter AGM",
|
||||
nominalVoltage: 12.0,
|
||||
capacityAmpHours: 100.0
|
||||
)
|
||||
]
|
||||
|
||||
SystemOverviewView(
|
||||
system: system,
|
||||
loads: loads,
|
||||
batteries: batteries,
|
||||
onSelectLoads: {},
|
||||
onSelectBatteries: {},
|
||||
onCreateLoad: {},
|
||||
onBrowseLibrary: {},
|
||||
onCreateBattery: {}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
|
||||
#Preview("SystemOverview – Empty States") {
|
||||
let system = ElectricalSystem(
|
||||
name: "24V DC System",
|
||||
location: "Main Panel",
|
||||
iconName: "bolt.circle.fill",
|
||||
colorName: "green"
|
||||
)
|
||||
|
||||
return SystemOverviewView(
|
||||
system: system,
|
||||
loads: [],
|
||||
batteries: [],
|
||||
onSelectLoads: {},
|
||||
onSelectBatteries: {},
|
||||
onCreateLoad: {},
|
||||
onBrowseLibrary: {},
|
||||
onCreateBattery: {}
|
||||
)
|
||||
.padding()
|
||||
}
|
||||
@@ -8,6 +8,8 @@ class SavedBattery {
|
||||
var nominalVoltage: Double
|
||||
var capacityAmpHours: Double
|
||||
private var chemistryRawValue: String
|
||||
var iconName: String = "battery.100"
|
||||
var colorName: String = "blue"
|
||||
var system: ElectricalSystem?
|
||||
var timestamp: Date
|
||||
|
||||
@@ -17,6 +19,8 @@ class SavedBattery {
|
||||
nominalVoltage: Double = 12.8,
|
||||
capacityAmpHours: Double = 100,
|
||||
chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate,
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem? = nil,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
@@ -25,6 +29,8 @@ class SavedBattery {
|
||||
self.nominalVoltage = nominalVoltage
|
||||
self.capacityAmpHours = capacityAmpHours
|
||||
self.chemistryRawValue = chemistry.rawValue
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// SystemView.swift
|
||||
// Cable
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SystemView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "square.grid.3x2")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("System View")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Coming soon - manage your electrical systems and panels here.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 48)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("System")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ struct SystemComponentsPersistence {
|
||||
for system: ElectricalSystem,
|
||||
in context: ModelContext,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery]
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> SavedLoad {
|
||||
let defaultName = String(
|
||||
localized: "default.load.new",
|
||||
@@ -16,7 +17,8 @@ struct SystemComponentsPersistence {
|
||||
let loadName = uniqueName(
|
||||
startingWith: defaultName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
@@ -40,14 +42,16 @@ struct SystemComponentsPersistence {
|
||||
for system: ElectricalSystem,
|
||||
in context: ModelContext,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery]
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> SavedLoad {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
|
||||
let loadName = uniqueName(
|
||||
startingWith: baseName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
let voltage = item.displayVoltage ?? 12.0
|
||||
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
||||
@@ -85,7 +89,8 @@ struct SystemComponentsPersistence {
|
||||
static func makeBatteryDraft(
|
||||
for system: ElectricalSystem,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery]
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> BatteryConfiguration {
|
||||
let defaultName = NSLocalizedString(
|
||||
"battery.editor.default_name",
|
||||
@@ -96,10 +101,39 @@ struct SystemComponentsPersistence {
|
||||
let batteryName = uniqueName(
|
||||
startingWith: defaultName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
return BatteryConfiguration(
|
||||
name: batteryName,
|
||||
iconName: "battery.100.bolt",
|
||||
colorName: system.colorName,
|
||||
system: system
|
||||
)
|
||||
}
|
||||
|
||||
static func makeChargerDraft(
|
||||
for system: ElectricalSystem,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> ChargerConfiguration {
|
||||
let defaultName = NSLocalizedString(
|
||||
"charger.editor.default_name",
|
||||
bundle: .main,
|
||||
value: "New Charger",
|
||||
comment: "Default name when configuring a new charger"
|
||||
)
|
||||
let chargerName = uniqueName(
|
||||
startingWith: defaultName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
return ChargerConfiguration(
|
||||
name: chargerName,
|
||||
iconName: "bolt.fill",
|
||||
colorName: system.colorName,
|
||||
system: system
|
||||
)
|
||||
}
|
||||
@@ -119,12 +153,38 @@ struct SystemComponentsPersistence {
|
||||
nominalVoltage: configuration.nominalVoltage,
|
||||
capacityAmpHours: configuration.capacityAmpHours,
|
||||
chemistry: configuration.chemistry,
|
||||
iconName: configuration.iconName,
|
||||
colorName: configuration.colorName,
|
||||
system: system
|
||||
)
|
||||
context.insert(newBattery)
|
||||
}
|
||||
}
|
||||
|
||||
static func saveCharger(
|
||||
_ configuration: ChargerConfiguration,
|
||||
for system: ElectricalSystem,
|
||||
existingChargers: [SavedCharger],
|
||||
in context: ModelContext
|
||||
) {
|
||||
if let existing = existingChargers.first(where: { $0.id == configuration.id }) {
|
||||
configuration.apply(to: existing)
|
||||
} else {
|
||||
let newCharger = SavedCharger(
|
||||
id: configuration.id,
|
||||
name: configuration.name,
|
||||
inputVoltage: configuration.inputVoltage,
|
||||
outputVoltage: configuration.outputVoltage,
|
||||
maxCurrentAmps: configuration.maxCurrentAmps,
|
||||
maxPowerWatts: configuration.maxPowerWatts,
|
||||
iconName: configuration.iconName,
|
||||
colorName: configuration.colorName,
|
||||
system: system
|
||||
)
|
||||
context.insert(newCharger)
|
||||
}
|
||||
}
|
||||
|
||||
static func deleteBatteries(
|
||||
at offsets: IndexSet,
|
||||
from batteries: [SavedBattery],
|
||||
@@ -135,12 +195,27 @@ struct SystemComponentsPersistence {
|
||||
}
|
||||
}
|
||||
|
||||
static func deleteChargers(
|
||||
at offsets: IndexSet,
|
||||
from chargers: [SavedCharger],
|
||||
in context: ModelContext
|
||||
) {
|
||||
for index in offsets {
|
||||
context.delete(chargers[index])
|
||||
}
|
||||
}
|
||||
|
||||
static func uniqueName(
|
||||
startingWith baseName: String,
|
||||
loads: [SavedLoad],
|
||||
batteries: [SavedBattery]
|
||||
batteries: [SavedBattery],
|
||||
chargers: [SavedCharger]
|
||||
) -> String {
|
||||
let existingNames = Set(loads.map { $0.name } + batteries.map { $0.name })
|
||||
let existingNames = Set(
|
||||
loads.map { $0.name } +
|
||||
batteries.map { $0.name } +
|
||||
chargers.map { $0.name }
|
||||
)
|
||||
|
||||
if !existingNames.contains(baseName) {
|
||||
return baseName
|
||||
@@ -156,4 +231,35 @@ struct SystemComponentsPersistence {
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
static func createDefaultCharger(
|
||||
for system: ElectricalSystem,
|
||||
in context: ModelContext,
|
||||
existingLoads: [SavedLoad],
|
||||
existingBatteries: [SavedBattery],
|
||||
existingChargers: [SavedCharger]
|
||||
) -> SavedCharger {
|
||||
let defaultName = String(
|
||||
localized: "charger.default.new",
|
||||
bundle: .main,
|
||||
comment: "Default name when creating a new charger from system view"
|
||||
)
|
||||
let chargerName = uniqueName(
|
||||
startingWith: defaultName,
|
||||
loads: existingLoads,
|
||||
batteries: existingBatteries,
|
||||
chargers: existingChargers
|
||||
)
|
||||
let charger = SavedCharger(
|
||||
name: chargerName,
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 30,
|
||||
iconName: "bolt.fill",
|
||||
colorName: system.colorName,
|
||||
system: system
|
||||
)
|
||||
context.insert(charger)
|
||||
return charger
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ struct SystemsView: View {
|
||||
@State private var systemNavigationTarget: SystemNavigationTarget?
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSettings = false
|
||||
@State private var hasPerformedInitialAutoNavigation = false
|
||||
|
||||
private let systemColorOptions = [
|
||||
"blue", "green", "orange", "red", "purple", "yellow",
|
||||
@@ -79,7 +80,7 @@ struct SystemsView: View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(colorForName(system.colorName))
|
||||
.fill(Color.componentColor(named: system.colorName))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: system.iconName)
|
||||
@@ -140,6 +141,9 @@ struct SystemsView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
performInitialAutoNavigationIfNeeded()
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
addComponentFromLibrary(item)
|
||||
@@ -219,6 +223,14 @@ struct SystemsView: View {
|
||||
return newSystem
|
||||
}
|
||||
|
||||
private func performInitialAutoNavigationIfNeeded() {
|
||||
guard !hasPerformedInitialAutoNavigation else { return }
|
||||
hasPerformedInitialAutoNavigation = true
|
||||
|
||||
guard systems.count == 1, let system = systems.first else { return }
|
||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil, animated: false)
|
||||
}
|
||||
|
||||
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
||||
let system = makeSystem()
|
||||
let load = createLoad(from: item, in: system)
|
||||
@@ -383,22 +395,80 @@ struct SystemsView: View {
|
||||
return uniqueKeywords
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Sample Systems") {
|
||||
// An in-memory SwiftData container for previews so we don't persist anything
|
||||
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
let container = try! ModelContainer(for: ElectricalSystem.self, SavedLoad.self, configurations: configuration)
|
||||
|
||||
// Seed sample data only once per preview session
|
||||
if (try? ModelContext(container).fetch(FetchDescriptor<ElectricalSystem>()))?.isEmpty ?? true {
|
||||
let context = ModelContext(container)
|
||||
|
||||
// Sample systems
|
||||
let system1 = ElectricalSystem(name: "Camper Van", location: "Road Trip", iconName: "bus", colorName: "teal")
|
||||
let system2 = ElectricalSystem(name: "Sailboat Aurora", location: "Marina 7", iconName: "sailboat", colorName: "blue")
|
||||
|
||||
context.insert(system1)
|
||||
context.insert(system2)
|
||||
|
||||
// Sample loads for system 1
|
||||
let load1 = SavedLoad(
|
||||
name: "LED Cabin Light",
|
||||
voltage: 12,
|
||||
current: 0.5,
|
||||
power: 6,
|
||||
length: 5,
|
||||
crossSection: 1.5,
|
||||
iconName: "lightbulb",
|
||||
colorName: "yellow",
|
||||
isWattMode: false,
|
||||
system: system1,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
)
|
||||
|
||||
let load2 = SavedLoad(
|
||||
name: "Water Pump",
|
||||
voltage: 12,
|
||||
current: 5,
|
||||
power: 60,
|
||||
length: 3,
|
||||
crossSection: 2.5,
|
||||
iconName: "drop",
|
||||
colorName: "blue",
|
||||
isWattMode: false,
|
||||
system: system1,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
)
|
||||
|
||||
// Sample loads for system 2
|
||||
let load3 = SavedLoad(
|
||||
name: "Navigation Lights",
|
||||
voltage: 12,
|
||||
current: 1.2,
|
||||
power: 14.4,
|
||||
length: 8,
|
||||
crossSection: 1.5,
|
||||
iconName: "lightbulb",
|
||||
colorName: "green",
|
||||
isWattMode: false,
|
||||
system: system2,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
)
|
||||
|
||||
context.insert(load1)
|
||||
context.insert(load2)
|
||||
context.insert(load3)
|
||||
}
|
||||
|
||||
return SystemsView()
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
@@ -31,14 +31,20 @@ private extension UITestSampleData {
|
||||
static func clearExistingData(in context: ModelContext) throws {
|
||||
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
||||
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
||||
let batteryDescriptor = FetchDescriptor<SavedBattery>()
|
||||
let chargerDescriptor = FetchDescriptor<SavedCharger>()
|
||||
let itemDescriptor = FetchDescriptor<Item>()
|
||||
|
||||
let systems = try context.fetch(systemDescriptor)
|
||||
let loads = try context.fetch(loadDescriptor)
|
||||
let batteries = try context.fetch(batteryDescriptor)
|
||||
let chargers = try context.fetch(chargerDescriptor)
|
||||
let items = try context.fetch(itemDescriptor)
|
||||
|
||||
systems.forEach { context.delete($0) }
|
||||
loads.forEach { context.delete($0) }
|
||||
batteries.forEach { context.delete($0) }
|
||||
chargers.forEach { context.delete($0) }
|
||||
items.forEach { context.delete($0) }
|
||||
}
|
||||
|
||||
@@ -123,6 +129,47 @@ private extension UITestSampleData {
|
||||
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
|
||||
|
||||
[vanFridge, vanLighting, workshopCompressor, workshopCharger].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,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40.0,
|
||||
maxPowerWatts: 600.0,
|
||||
iconName: "powerplug",
|
||||
colorName: "orange",
|
||||
system: adventureVan,
|
||||
identifier: "sample.charger.shore"
|
||||
)
|
||||
shoreCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1300)
|
||||
|
||||
let alternatorCharger = SavedCharger(
|
||||
name: String(localized: "sample.charger.dcdc.name", comment: "Sample data name for a DC-DC charger"),
|
||||
inputVoltage: 12.8,
|
||||
outputVoltage: 14.2,
|
||||
maxCurrentAmps: 30.0,
|
||||
maxPowerWatts: 0.0,
|
||||
iconName: "bolt.badge.clock",
|
||||
colorName: "blue",
|
||||
system: adventureVan,
|
||||
identifier: "sample.charger.dcdc"
|
||||
)
|
||||
alternatorCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1350)
|
||||
|
||||
let benchCharger = SavedCharger(
|
||||
name: String(localized: "sample.charger.workbench.name", comment: "Sample data name for a workbench charger"),
|
||||
inputVoltage: 120.0,
|
||||
outputVoltage: 14.6,
|
||||
maxCurrentAmps: 25.0,
|
||||
maxPowerWatts: 365.0,
|
||||
iconName: "bolt",
|
||||
colorName: "green",
|
||||
system: workshopBench,
|
||||
identifier: "sample.charger.workbench"
|
||||
)
|
||||
benchCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2250)
|
||||
|
||||
[shoreCharger, alternatorCharger, benchCharger].forEach { context.insert($0) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -141,10 +141,16 @@
|
||||
"tab.batteries" = "Batterien";
|
||||
"tab.chargers" = "Ladegeräte";
|
||||
|
||||
"loads.overview.header.title" = "Verbraucherübersicht";
|
||||
"loads.overview.header.title" = "Verbraucher";
|
||||
"loads.overview.metric.count" = "Verbraucher";
|
||||
"loads.overview.metric.current" = "Strom";
|
||||
"loads.overview.metric.power" = "Leistung";
|
||||
"loads.overview.empty.message" = "Füge einen Verbraucher hinzu, um dein System einzurichten.";
|
||||
"loads.overview.empty.create" = "Verbraucher hinzufügen";
|
||||
"loads.overview.empty.library" = "Bibliothek durchsuchen";
|
||||
"loads.library.button" = "Bibliothek";
|
||||
"loads.onboarding.title" = "Füge deinen ersten Verbraucher hinzu";
|
||||
"loads.onboarding.subtitle" = "Statte dein System mit Verbrauchern aus und lass **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen übernehmen.";
|
||||
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
|
||||
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten.";
|
||||
"loads.overview.status.missing_details.singular" = "Verbraucher";
|
||||
@@ -162,10 +168,13 @@
|
||||
"battery.bank.warning.voltage.short" = "Spannung";
|
||||
"battery.bank.warning.capacity.short" = "Kapazität";
|
||||
|
||||
"battery.bank.header.title" = "Batteriebank";
|
||||
"battery.bank.header.title" = "Batterien";
|
||||
"battery.bank.metric.count" = "Batterien";
|
||||
"battery.bank.metric.capacity" = "Kapazität";
|
||||
"battery.bank.metric.energy" = "Energie";
|
||||
"battery.overview.empty.create" = "Batterie hinzufügen";
|
||||
"battery.onboarding.title" = "Füge deine erste Batterie hinzu";
|
||||
"battery.onboarding.subtitle" = "Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten.";
|
||||
"battery.bank.badge.voltage" = "Spannung";
|
||||
"battery.bank.badge.capacity" = "Kapazität";
|
||||
"battery.bank.badge.energy" = "Energie";
|
||||
@@ -200,5 +209,48 @@
|
||||
"battery.editor.alert.save" = "Speichern";
|
||||
"battery.editor.default_name" = "Neue Batterie";
|
||||
|
||||
"charger.editor.title" = "Ladegerät";
|
||||
"charger.editor.field.name" = "Name";
|
||||
"charger.editor.placeholder.name" = "Werkstattladegerät";
|
||||
"charger.editor.section.electrical" = "Elektrik";
|
||||
"charger.editor.section.power" = "Ladeausgang";
|
||||
"charger.editor.appearance.title" = "Ladegerät-Darstellung";
|
||||
"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird";
|
||||
"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten";
|
||||
"charger.editor.field.input_voltage" = "Eingangsspannung";
|
||||
"charger.editor.field.output_voltage" = "Ausgangsspannung";
|
||||
"charger.editor.field.current" = "Ladestrom";
|
||||
"charger.editor.field.power" = "Ladeleistung";
|
||||
"charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom.";
|
||||
"charger.editor.default_name" = "Neues Ladegerät";
|
||||
"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten";
|
||||
"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten";
|
||||
"charger.editor.alert.current.title" = "Ladestrom bearbeiten";
|
||||
"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
|
||||
"charger.editor.alert.power.title" = "Ladeleistung bearbeiten";
|
||||
"charger.editor.alert.power.placeholder" = "Leistung";
|
||||
"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben";
|
||||
"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben";
|
||||
"charger.editor.alert.cancel" = "Abbrechen";
|
||||
"charger.editor.alert.save" = "Speichern";
|
||||
"charger.default.new" = "Neues Ladegerät";
|
||||
|
||||
"chargers.summary.title" = "Ladeübersicht";
|
||||
"chargers.summary.metric.count" = "Ladegeräte";
|
||||
"chargers.summary.metric.output" = "Spannung";
|
||||
"chargers.summary.metric.current" = "Ladestrom";
|
||||
"chargers.summary.metric.power" = "Ladeleistung";
|
||||
"chargers.badge.input" = "Eingang";
|
||||
"chargers.badge.output" = "Ausgang";
|
||||
"chargers.badge.current" = "Strom";
|
||||
"chargers.badge.power" = "Leistung";
|
||||
"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu";
|
||||
"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten.";
|
||||
"chargers.onboarding.primary" = "Ladegerät erstellen";
|
||||
|
||||
"sample.charger.shore.name" = "Landstrom-Ladegerät";
|
||||
"sample.charger.dcdc.name" = "DC-DC-Ladegerät";
|
||||
"sample.charger.workbench.name" = "Werkbank-Ladegerät";
|
||||
|
||||
"chargers.title" = "Ladegeräte für %@";
|
||||
"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar.";
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"loads.overview.metric.count" = "Cargas";
|
||||
"loads.overview.metric.current" = "Corriente total";
|
||||
"loads.overview.metric.power" = "Potencia total";
|
||||
"loads.overview.empty.message" = "Añade una carga para ver los detalles del sistema.";
|
||||
"loads.overview.empty.create" = "Añadir carga";
|
||||
"loads.overview.empty.library" = "Explorar biblioteca";
|
||||
"loads.library.button" = "Biblioteca";
|
||||
"loads.onboarding.title" = "Añade tu primer consumidor";
|
||||
"loads.onboarding.subtitle" = "Completa tu sistema con consumidores y deja que **Cable by VoltPlan** calcule cables y fusibles por ti.";
|
||||
"loads.overview.status.missing_details.title" = "Faltan detalles de la carga";
|
||||
"loads.overview.status.missing_details.message" = "Introduce la longitud del cable y el calibre del conductor para %d %@ para obtener recomendaciones precisas.";
|
||||
"loads.overview.status.missing_details.singular" = "carga";
|
||||
@@ -165,6 +171,9 @@
|
||||
"battery.bank.metric.count" = "Baterías";
|
||||
"battery.bank.metric.capacity" = "Capacidad";
|
||||
"battery.bank.metric.energy" = "Energía";
|
||||
"battery.overview.empty.create" = "Añadir batería";
|
||||
"battery.onboarding.title" = "Añade tu primera batería";
|
||||
"battery.onboarding.subtitle" = "Controla la capacidad y la química del banco para mantener tus tiempos de autonomía bajo control.";
|
||||
"battery.bank.badge.voltage" = "Voltaje";
|
||||
"battery.bank.badge.capacity" = "Capacidad";
|
||||
"battery.bank.badge.energy" = "Energía";
|
||||
@@ -199,5 +208,48 @@
|
||||
"battery.editor.alert.save" = "Guardar";
|
||||
"battery.editor.default_name" = "Nueva batería";
|
||||
|
||||
"charger.editor.title" = "Cargador";
|
||||
"charger.editor.field.name" = "Nombre";
|
||||
"charger.editor.placeholder.name" = "Cargador de taller";
|
||||
"charger.editor.section.electrical" = "Eléctrico";
|
||||
"charger.editor.section.power" = "Salida de carga";
|
||||
"charger.editor.appearance.title" = "Apariencia del cargador";
|
||||
"charger.editor.appearance.subtitle" = "Personaliza cómo se muestra este cargador";
|
||||
"charger.editor.appearance.accessibility" = "Editar apariencia del cargador";
|
||||
"charger.editor.field.input_voltage" = "Voltaje de entrada";
|
||||
"charger.editor.field.output_voltage" = "Voltaje de salida";
|
||||
"charger.editor.field.current" = "Corriente de carga";
|
||||
"charger.editor.field.power" = "Potencia de carga";
|
||||
"charger.editor.field.power.footer" = "Déjalo en blanco si no se publica la potencia nominal. La calcularemos a partir del voltaje y la corriente.";
|
||||
"charger.editor.default_name" = "Nuevo cargador";
|
||||
"charger.editor.alert.input_voltage.title" = "Editar voltaje de entrada";
|
||||
"charger.editor.alert.output_voltage.title" = "Editar voltaje de salida";
|
||||
"charger.editor.alert.current.title" = "Editar corriente de carga";
|
||||
"charger.editor.alert.voltage.message" = "Introduce el voltaje en voltios (V)";
|
||||
"charger.editor.alert.power.title" = "Editar potencia de carga";
|
||||
"charger.editor.alert.power.placeholder" = "Potencia";
|
||||
"charger.editor.alert.power.message" = "Introduce la potencia en vatios (W)";
|
||||
"charger.editor.alert.current.message" = "Introduce la corriente en amperios (A)";
|
||||
"charger.editor.alert.cancel" = "Cancelar";
|
||||
"charger.editor.alert.save" = "Guardar";
|
||||
"charger.default.new" = "Nuevo cargador";
|
||||
|
||||
"chargers.summary.title" = "Resumen de carga";
|
||||
"chargers.summary.metric.count" = "Cargadores";
|
||||
"chargers.summary.metric.output" = "Voltaje de salida";
|
||||
"chargers.summary.metric.current" = "Tasa de carga";
|
||||
"chargers.summary.metric.power" = "Potencia de carga";
|
||||
"chargers.badge.input" = "Entrada";
|
||||
"chargers.badge.output" = "Salida";
|
||||
"chargers.badge.current" = "Corriente";
|
||||
"chargers.badge.power" = "Potencia";
|
||||
"chargers.onboarding.title" = "Añade tus cargadores";
|
||||
"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.charger.shore.name" = "Cargador de costa";
|
||||
"sample.charger.dcdc.name" = "Cargador DC-DC";
|
||||
"sample.charger.workbench.name" = "Cargador de banco de trabajo";
|
||||
|
||||
"chargers.title" = "Cargadores para %@";
|
||||
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto.";
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"loads.overview.metric.count" = "Charges";
|
||||
"loads.overview.metric.current" = "Courant total";
|
||||
"loads.overview.metric.power" = "Puissance totale";
|
||||
"loads.overview.empty.message" = "Ajoutez une charge pour voir les informations du système.";
|
||||
"loads.overview.empty.create" = "Ajouter une charge";
|
||||
"loads.overview.empty.library" = "Parcourir la bibliothèque";
|
||||
"loads.library.button" = "Bibliothèque";
|
||||
"loads.onboarding.title" = "Ajoutez votre premier consommateur";
|
||||
"loads.onboarding.subtitle" = "Complétez votre système avec des équipements et laissez **Cable by VoltPlan** proposer les câbles et fusibles adaptés.";
|
||||
"loads.overview.status.missing_details.title" = "Détails de charge manquants";
|
||||
"loads.overview.status.missing_details.message" = "Saisissez la longueur de câble et la section du conducteur pour %d %@ afin d'obtenir des recommandations précises.";
|
||||
"loads.overview.status.missing_details.singular" = "charge";
|
||||
@@ -165,6 +171,9 @@
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
"battery.bank.metric.capacity" = "Capacité";
|
||||
"battery.bank.metric.energy" = "Énergie";
|
||||
"battery.overview.empty.create" = "Ajouter une batterie";
|
||||
"battery.onboarding.title" = "Ajoutez votre première batterie";
|
||||
"battery.onboarding.subtitle" = "Suivez la capacité et la chimie de votre banc pour mieux maîtriser l'autonomie.";
|
||||
"battery.bank.badge.voltage" = "Tension";
|
||||
"battery.bank.badge.capacity" = "Capacité";
|
||||
"battery.bank.badge.energy" = "Énergie";
|
||||
@@ -199,5 +208,48 @@
|
||||
"battery.editor.alert.save" = "Enregistrer";
|
||||
"battery.editor.default_name" = "Nouvelle batterie";
|
||||
|
||||
"charger.editor.title" = "Chargeur";
|
||||
"charger.editor.field.name" = "Nom";
|
||||
"charger.editor.placeholder.name" = "Chargeur d'atelier";
|
||||
"charger.editor.section.electrical" = "Électrique";
|
||||
"charger.editor.section.power" = "Sortie de charge";
|
||||
"charger.editor.appearance.title" = "Apparence du chargeur";
|
||||
"charger.editor.appearance.subtitle" = "Personnalisez l'affichage de ce chargeur";
|
||||
"charger.editor.appearance.accessibility" = "Modifier l'apparence du chargeur";
|
||||
"charger.editor.field.input_voltage" = "Tension d'entrée";
|
||||
"charger.editor.field.output_voltage" = "Tension de sortie";
|
||||
"charger.editor.field.current" = "Courant de charge";
|
||||
"charger.editor.field.power" = "Puissance de charge";
|
||||
"charger.editor.field.power.footer" = "Laissez vide si la puissance nominale n'est pas indiquée. Nous la calculerons à partir de la tension et du courant.";
|
||||
"charger.editor.default_name" = "Nouveau chargeur";
|
||||
"charger.editor.alert.input_voltage.title" = "Modifier la tension d'entrée";
|
||||
"charger.editor.alert.output_voltage.title" = "Modifier la tension de sortie";
|
||||
"charger.editor.alert.current.title" = "Modifier le courant de charge";
|
||||
"charger.editor.alert.voltage.message" = "Saisissez la tension en volts (V)";
|
||||
"charger.editor.alert.power.title" = "Modifier la puissance de charge";
|
||||
"charger.editor.alert.power.placeholder" = "Puissance";
|
||||
"charger.editor.alert.power.message" = "Saisissez la puissance en watts (W)";
|
||||
"charger.editor.alert.current.message" = "Saisissez le courant en ampères (A)";
|
||||
"charger.editor.alert.cancel" = "Annuler";
|
||||
"charger.editor.alert.save" = "Enregistrer";
|
||||
"charger.default.new" = "Nouveau chargeur";
|
||||
|
||||
"chargers.summary.title" = "Aperçu de charge";
|
||||
"chargers.summary.metric.count" = "Chargeurs";
|
||||
"chargers.summary.metric.output" = "Tension de sortie";
|
||||
"chargers.summary.metric.current" = "Courant de charge";
|
||||
"chargers.summary.metric.power" = "Puissance de charge";
|
||||
"chargers.badge.input" = "Entrée";
|
||||
"chargers.badge.output" = "Sortie";
|
||||
"chargers.badge.current" = "Courant";
|
||||
"chargers.badge.power" = "Puissance";
|
||||
"chargers.onboarding.title" = "Ajoutez vos chargeurs";
|
||||
"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.charger.shore.name" = "Chargeur de quai";
|
||||
"sample.charger.dcdc.name" = "Chargeur DC-DC";
|
||||
"sample.charger.workbench.name" = "Chargeur d'établi";
|
||||
|
||||
"chargers.title" = "Chargeurs pour %@";
|
||||
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
|
||||
|
||||
@@ -144,6 +144,12 @@
|
||||
"loads.overview.metric.count" = "Lasten";
|
||||
"loads.overview.metric.current" = "Totale stroom";
|
||||
"loads.overview.metric.power" = "Totaal vermogen";
|
||||
"loads.overview.empty.message" = "Voeg een belasting toe om systeeminformatie te zien.";
|
||||
"loads.overview.empty.create" = "Belasting toevoegen";
|
||||
"loads.overview.empty.library" = "Bibliotheek bekijken";
|
||||
"loads.library.button" = "Bibliotheek";
|
||||
"loads.onboarding.title" = "Voeg je eerste verbruiker toe";
|
||||
"loads.onboarding.subtitle" = "Bouw je systeem uit met verbruikers en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen.";
|
||||
"loads.overview.status.missing_details.title" = "Ontbrekende lastdetails";
|
||||
"loads.overview.status.missing_details.message" = "Voer kabellengte en kabeldoorsnede in voor %d %@ om nauwkeurige aanbevelingen te krijgen.";
|
||||
"loads.overview.status.missing_details.singular" = "last";
|
||||
@@ -165,6 +171,9 @@
|
||||
"battery.bank.metric.count" = "Batterijen";
|
||||
"battery.bank.metric.capacity" = "Capaciteit";
|
||||
"battery.bank.metric.energy" = "Energie";
|
||||
"battery.overview.empty.create" = "Accu toevoegen";
|
||||
"battery.onboarding.title" = "Voeg je eerste accu toe";
|
||||
"battery.onboarding.subtitle" = "Houd capaciteit en chemie van de accubank in de gaten om de gebruiksduur te beheersen.";
|
||||
"battery.bank.badge.voltage" = "Spanning";
|
||||
"battery.bank.badge.capacity" = "Capaciteit";
|
||||
"battery.bank.badge.energy" = "Energie";
|
||||
@@ -199,5 +208,48 @@
|
||||
"battery.editor.alert.save" = "Opslaan";
|
||||
"battery.editor.default_name" = "Nieuwe batterij";
|
||||
|
||||
"charger.editor.title" = "Lader";
|
||||
"charger.editor.field.name" = "Naam";
|
||||
"charger.editor.placeholder.name" = "Werkplaatslader";
|
||||
"charger.editor.section.electrical" = "Elektrisch";
|
||||
"charger.editor.section.power" = "Laaduitgang";
|
||||
"charger.editor.appearance.title" = "Uiterlijk van lader";
|
||||
"charger.editor.appearance.subtitle" = "Bepaal hoe deze lader wordt weergegeven";
|
||||
"charger.editor.appearance.accessibility" = "Uiterlijk van lader bewerken";
|
||||
"charger.editor.field.input_voltage" = "Ingangsspanning";
|
||||
"charger.editor.field.output_voltage" = "Uitgangsspanning";
|
||||
"charger.editor.field.current" = "Laadstroom";
|
||||
"charger.editor.field.power" = "Laadvermogen";
|
||||
"charger.editor.field.power.footer" = "Laat leeg als het opgegeven vermogen ontbreekt. We berekenen het uit spanning en stroom.";
|
||||
"charger.editor.default_name" = "Nieuwe lader";
|
||||
"charger.editor.alert.input_voltage.title" = "Ingangsspanning bewerken";
|
||||
"charger.editor.alert.output_voltage.title" = "Uitgangsspanning bewerken";
|
||||
"charger.editor.alert.current.title" = "Laadstroom bewerken";
|
||||
"charger.editor.alert.voltage.message" = "Voer de spanning in volt (V) in";
|
||||
"charger.editor.alert.power.title" = "Laadvermogen bewerken";
|
||||
"charger.editor.alert.power.placeholder" = "Vermogen";
|
||||
"charger.editor.alert.power.message" = "Voer het vermogen in watt (W) in";
|
||||
"charger.editor.alert.current.message" = "Voer de stroom in ampère (A) in";
|
||||
"charger.editor.alert.cancel" = "Annuleren";
|
||||
"charger.editor.alert.save" = "Opslaan";
|
||||
"charger.default.new" = "Nieuwe lader";
|
||||
|
||||
"chargers.summary.title" = "Laadoverzicht";
|
||||
"chargers.summary.metric.count" = "Laders";
|
||||
"chargers.summary.metric.output" = "Uitgangsspanning";
|
||||
"chargers.summary.metric.current" = "Laadstroom";
|
||||
"chargers.summary.metric.power" = "Laadvermogen";
|
||||
"chargers.badge.input" = "Ingang";
|
||||
"chargers.badge.output" = "Uitgang";
|
||||
"chargers.badge.current" = "Stroom";
|
||||
"chargers.badge.power" = "Vermogen";
|
||||
"chargers.onboarding.title" = "Voeg je laders toe";
|
||||
"chargers.onboarding.subtitle" = "Houd walstroom, boosters en zonne-regelaars bij om je laadcapaciteit te kennen.";
|
||||
"chargers.onboarding.primary" = "Lader aanmaken";
|
||||
|
||||
"sample.charger.shore.name" = "Walstroomlader";
|
||||
"sample.charger.dcdc.name" = "DC-DC-lader";
|
||||
"sample.charger.workbench.name" = "Werkplaatslader";
|
||||
|
||||
"chargers.title" = "Laders voor %@";
|
||||
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";
|
||||
|
||||
@@ -4,4 +4,4 @@ LoadEditorView=Berechne*zuverlässig*\ndie richtige Sicherung
|
||||
ComponentSelectorView=Finde im*großen*Teilekatalog\nwas du suchst
|
||||
SystemsWithSampleData=Navigiere*schnell*\ndurch Deine Systeme
|
||||
AdventureVanLoads=Erstelle*individuelle*\nVerbraucher für Dein System
|
||||
AdventureVanBillOfMaterials=Behalte den*Überblick*\nwas du schon gekauft hast
|
||||
AdventureVanBillOfMaterials=Behalte den*Überblick*\nwelche Teile du schon hast
|
||||
Reference in New Issue
Block a user