Compare commits

..

25 Commits

Author SHA1 Message Date
Stefan Lange-Hegermann
8da6987f32 better overview design in dark mode 2025-11-07 21:11:59 +01:00
Stefan Lange-Hegermann
b11d627fdb PDF BOM export 2025-11-07 11:18:03 +01:00
Stefan Lange-Hegermann
ced06f9eb6 ads tracking 2025-11-05 11:13:54 +01:00
Stefan Lange-Hegermann
5fcc33529a beautiful glass design 2025-10-29 15:23:13 +01:00
Stefan Lange-Hegermann
97a9d3903c much better system overview 2025-10-29 12:55:47 +01:00
Stefan Lange-Hegermann
45a462295d system overview is cleaner now 2025-10-29 10:22:54 +01:00
Stefan Lange-Hegermann
10dc0e4fa9 translated missing elements 2025-10-28 23:23:40 +01:00
Stefan Lange-Hegermann
8868368392 better layout of the advanced sections 2025-10-28 22:53:37 +01:00
Stefan Lange-Hegermann
a2314585ea added subscription booking 2025-10-28 22:43:41 +01:00
Stefan Lange-Hegermann
46664625b4 some advanced settings 2025-10-28 13:31:51 +01:00
Stefan Lange-Hegermann
0989c68aa7 well designed system overview 2025-10-23 17:23:38 +02:00
Stefan Lange-Hegermann
51d85cc352 chargers in overview 2025-10-23 15:27:22 +02:00
Stefan Lange-Hegermann
cd8a043c5c finally consistent design for the chargers tab 2025-10-23 15:14:57 +02:00
Stefan Lange-Hegermann
0720529821 adds chargers 2025-10-23 14:09:16 +02:00
Stefan Lange-Hegermann
6258a6a66f more consitancy 2025-10-22 22:43:03 +02:00
Stefan Lange-Hegermann
802b111aa7 onboarding buttons in the system overview 2025-10-22 17:17:57 +02:00
Stefan Lange-Hegermann
c7ff9322ef useful battery editor view 2025-10-21 23:17:53 +02:00
Stefan Lange-Hegermann
d081a79b59 better battery editor view 2025-10-21 23:00:56 +02:00
Stefan Lange-Hegermann
9f8d8e5149 slight icon change 2025-10-21 22:26:14 +02:00
Stefan Lange-Hegermann
858bf2a305 calculator allows manual entries too 2025-10-21 16:42:25 +02:00
Stefan Lange-Hegermann
f171c3d6b2 free value entry in the battery editor 2025-10-21 16:24:25 +02:00
Stefan Lange-Hegermann
a6f2f8fc91 some fixes 2025-10-21 15:37:24 +02:00
Stefan Lange-Hegermann
1fef290abf some fixes 2025-10-21 15:37:07 +02:00
Stefan Lange-Hegermann
df315ea7d8 All localized 2025-10-21 13:55:44 +02:00
Stefan Lange-Hegermann
2a2c48e89f loads info bar above list 2025-10-21 11:43:56 +02:00
60 changed files with 12105 additions and 2496 deletions

View File

@@ -45,11 +45,21 @@
);
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
};
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CableUITestsScreenshotLaunchTests.swift,
);
target = 3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */,
);
path = CableUITestsScreenshot;
sourceTree = "<group>";
};
@@ -113,6 +123,7 @@
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
3E5C0BCD2E72C0FD00247EC8 /* Products */,
57738E9B07763CFA62681EEE /* Pods */,
);
sourceTree = "<group>";
};
@@ -127,6 +138,13 @@
name = Products;
sourceTree = "<group>";
};
57738E9B07763CFA62681EEE /* Pods */ = {
isa = PBXGroup;
children = (
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -147,8 +165,6 @@
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
);
name = CableUITestsScreenshot;
packageProductDependencies = (
);
productName = CableUITestsScreenshot;
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -169,8 +185,6 @@
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
);
name = Cable;
packageProductDependencies = (
);
productName = Cable;
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
productType = "com.apple.product-type.application";
@@ -192,8 +206,6 @@
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
);
name = CableTests;
packageProductDependencies = (
);
productName = CableTests;
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
@@ -215,8 +227,6 @@
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
);
name = CableUITests;
packageProductDependencies = (
);
productName = CableUITests;
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -405,10 +415,11 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist;
@@ -422,7 +433,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,10 +451,11 @@
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 14;
CURRENT_PROJECT_VERSION = 39;
DEVELOPMENT_TEAM = RE4FXQ754N;
ENABLE_APP_SANDBOX = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Cable/Info.plist;
@@ -457,7 +469,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 = "";

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Cable.xcodeproj">
</FileRef>
</Workspace>

36
Cable/AppDelegate.swift Normal file
View File

@@ -0,0 +1,36 @@
//
// AppDelegate.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 01.11.25.
//
import Foundation
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
AnalyticsTracker.configure()
NSLog("Launched")
return true
}
}
enum AnalyticsTracker {
static func configure() {}
static func log(_ event: String, properties: [String: Any] = [:]) {
#if DEBUG
if properties.isEmpty {
NSLog("Analytics: %@", event)
} else {
let formatted = properties
.map { "\($0.key)=\($0.value)" }
.sorted()
.joined(separator: ", ")
NSLog("Analytics: %@ { %@ }", event, formatted)
}
#endif
}
}

View File

@@ -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"
]
},
@@ -44,7 +44,7 @@
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.31812,0.56494,0.59766,1.00000"
"solid" : "display-p3:0.31765,0.56471,0.59608,1.00000"
}
},
{

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -2,6 +2,77 @@
"affiliate.description.with_link" = "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.";
"affiliate.description.without_link" = "Tapping above shows a full bill of materials with shopping searches to help you source parts.";
"affiliate.disclaimer" = "Purchases through affiliate links may support VoltPlan.";
"battery.bank.badge.capacity" = "Capacity";
"battery.bank.badge.energy" = "Energy";
"battery.bank.badge.voltage" = "Voltage";
"battery.bank.banner.capacity" = "Capacity mismatch detected";
"battery.bank.banner.voltage" = "Voltage mismatch detected";
"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@.";
"battery.bank.empty.title" = "No Batteries Yet";
"battery.bank.header.title" = "Battery Bank";
"battery.bank.metric.capacity" = "Capacity";
"battery.bank.metric.count" = "Batteries";
"battery.bank.metric.energy" = "Energy";
"battery.bank.metric.usable_capacity" = "Usable Capacity";
"battery.bank.metric.usable_energy" = "Usable Energy";
"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.";
"battery.bank.status.capacity.title" = "Capacity mismatch";
"battery.bank.status.dismiss" = "Got it";
"battery.bank.status.multiple.batteries" = "%d batteries";
"battery.bank.status.single.battery" = "One battery";
"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.";
"battery.bank.status.voltage.title" = "Voltage mismatch";
"battery.bank.warning.capacity.short" = "Capacity";
"battery.bank.warning.voltage.short" = "Voltage";
"battery.editor.advanced.usable_capacity.footer_default" = "Defaults to %@ based on chemistry.";
"battery.editor.advanced.usable_capacity.footer_override" = "Override active. Chemistry default remains %@.";
"battery.editor.advanced.charge_voltage.helper" = "Set the maximum recommended charging voltage.";
"battery.editor.advanced.cutoff_voltage.helper" = "Set the minimum safe discharge voltage.";
"battery.editor.advanced.temperature_range.helper" = "Define the recommended operating temperature range.";
"battery.editor.alert.charge_voltage.message" = "Enter charge voltage in volts (V)";
"battery.editor.alert.charge_voltage.placeholder" = "Charge Voltage";
"battery.editor.alert.charge_voltage.title" = "Edit Charge Voltage";
"battery.editor.alert.cutoff_voltage.message" = "Enter cut-off voltage in volts (V)";
"battery.editor.alert.cutoff_voltage.placeholder" = "Cut-off Voltage";
"battery.editor.alert.cutoff_voltage.title" = "Edit Cut-off Voltage";
"battery.editor.alert.maximum_temperature.message" = "Enter maximum temperature in degrees Celsius (\u00B0C)";
"battery.editor.alert.maximum_temperature.placeholder" = "Maximum Temperature (\u00B0C)";
"battery.editor.alert.maximum_temperature.title" = "Edit Maximum Temperature";
"battery.editor.alert.minimum_temperature.message" = "Enter minimum temperature in degrees Celsius (\u00B0C)";
"battery.editor.alert.minimum_temperature.placeholder" = "Minimum Temperature (\u00B0C)";
"battery.editor.alert.minimum_temperature.title" = "Edit Minimum Temperature";
"battery.editor.alert.cancel" = "Cancel";
"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)";
"battery.editor.alert.capacity.placeholder" = "Capacity";
"battery.editor.alert.capacity.title" = "Edit Capacity";
"battery.editor.alert.save" = "Save";
"battery.editor.alert.usable_capacity.message" = "Enter usable capacity percentage (%)";
"battery.editor.alert.usable_capacity.placeholder" = "Usable Capacity (%)";
"battery.editor.alert.usable_capacity.title" = "Edit Usable Capacity";
"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)";
"battery.editor.alert.voltage.placeholder" = "Voltage";
"battery.editor.alert.voltage.title" = "Edit Nominal Voltage";
"battery.editor.button.reset_default" = "Reset";
"battery.editor.cancel" = "Cancel";
"battery.editor.default_name" = "New Battery";
"battery.editor.field.chemistry" = "Chemistry";
"battery.editor.field.name" = "Name";
"battery.editor.placeholder.name" = "House Bank";
"battery.editor.save" = "Save";
"battery.editor.section.advanced" = "Advanced";
"battery.editor.section.summary" = "Summary";
"battery.editor.slider.capacity" = "Capacity";
"battery.editor.slider.charge_voltage" = "Charge Voltage";
"battery.editor.slider.cutoff_voltage" = "Cut-off Voltage";
"battery.editor.slider.temperature_range" = "Temperature Range";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.usable_capacity" = "Usable Capacity (%)";
"battery.editor.slider.voltage" = "Nominal Voltage";
"battery.editor.title" = "Battery Setup";
"battery.onboarding.subtitle" = "Track your bank's capacity and chemistry to keep runtime expectations in check.";
"battery.onboarding.title" = "Add your first battery";
"battery.overview.empty.create" = "Add Battery";
"bom.accessibility.mark.complete" = "Mark %@ complete";
"bom.accessibility.mark.incomplete" = "Mark %@ incomplete";
"bom.fuse.detail" = "Inline holder and %dA fuse";
@@ -13,11 +84,93 @@
"bom.navigation.title.system" = "BOM %@";
"bom.size.unknown" = "Size TBD";
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
"bom.empty.message" = "No components saved in this system yet.";
"bom.export.pdf.button" = "Export PDF";
"bom.export.pdf.error.title" = "Export Failed";
"bom.export.pdf.error.empty" = "Add at least one component before exporting.";
"bom.pdf.header.title" = "System Bill of Materials";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Unit System: %@";
"bom.pdf.placeholder.empty" = "No components available.";
"bom.pdf.page.number" = "Page %d";
"bom.category.components.title" = "Components & Chargers";
"bom.category.components.subtitle" = "Primary devices, controllers, and charging gear.";
"bom.category.batteries.title" = "Batteries";
"bom.category.batteries.subtitle" = "House banks and storage.";
"bom.category.cables.title" = "Cables";
"bom.category.cables.subtitle" = "Sized power runs for every circuit.";
"bom.category.fuses.title" = "Fuses";
"bom.category.fuses.subtitle" = "Circuit protection and holders.";
"bom.category.accessories.title" = "Accessories";
"bom.category.accessories.subtitle" = "Fuses, lugs, and supporting hardware.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"cable.pro.privacy.label" = "Privacy";
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
"cable.pro.terms.label" = "Terms";
"cable.pro.terms.url" = "https://voltplan.app/terms";
"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power.";
"calculator.advanced.duty_cycle.title" = "Duty Cycle";
"calculator.advanced.section.title" = "Advanced Settings";
"calculator.advanced.usage_hours.helper" = "Hours per day the load is turned on.";
"calculator.advanced.usage_hours.title" = "Daily On-Time";
"calculator.advanced.usage_hours.unit" = "h/day";
"calculator.alert.duty_cycle.message" = "Enter duty cycle as a percentage (0-100%).";
"calculator.alert.duty_cycle.placeholder" = "Duty Cycle";
"calculator.alert.duty_cycle.title" = "Edit Duty Cycle";
"calculator.alert.usage_hours.message" = "Enter the number of hours per day the load is active.";
"calculator.alert.usage_hours.placeholder" = "Daily On-Time";
"calculator.alert.usage_hours.title" = "Edit Daily On-Time";
"charger.default.new" = "New Charger";
"charger.editor.alert.cancel" = "Cancel";
"charger.editor.alert.current.message" = "Enter current in amps (A)";
"charger.editor.alert.current.title" = "Edit Charge Current";
"charger.editor.alert.input_voltage.title" = "Edit Input Voltage";
"charger.editor.alert.output_voltage.title" = "Edit Output Voltage";
"charger.editor.alert.power.message" = "Enter power in watts (W)";
"charger.editor.alert.power.placeholder" = "Power";
"charger.editor.alert.power.title" = "Edit Charge Power";
"charger.editor.alert.save" = "Save";
"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)";
"charger.editor.appearance.accessibility" = "Edit charger appearance";
"charger.editor.appearance.subtitle" = "Customize how this charger shows up";
"charger.editor.appearance.title" = "Charger Appearance";
"charger.editor.default_name" = "New Charger";
"charger.editor.field.current" = "Charge Current";
"charger.editor.field.input_voltage" = "Input Voltage";
"charger.editor.field.name" = "Name";
"charger.editor.field.output_voltage" = "Output Voltage";
"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.placeholder.name" = "Workshop Charger";
"charger.editor.section.electrical" = "Electrical";
"charger.editor.section.power" = "Charge Output";
"charger.editor.title" = "Charger";
"chargers.badge.current" = "Current";
"chargers.badge.input" = "Input";
"chargers.badge.output" = "Output";
"chargers.badge.power" = "Power";
"chargers.onboarding.primary" = "Create Charger";
"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity.";
"chargers.onboarding.title" = "Add your chargers";
"chargers.subtitle" = "Charger components will be available soon.";
"chargers.summary.metric.count" = "Chargers";
"chargers.summary.metric.current" = "Charge Rate";
"chargers.summary.metric.output" = "Output Voltage";
"chargers.summary.metric.power" = "Charge Power";
"chargers.summary.title" = "Charging Overview";
"chargers.title" = "Chargers for %@";
"component.fallback.name" = "Component";
"default.load.library" = "Library Load";
"default.load.name" = "My Load";
"default.load.unnamed" = "Unnamed Load";
"default.load.new" = "New Load";
"default.load.unnamed" = "Unnamed Load";
"default.system.name" = "My System";
"default.system.new" = "New System";
"editor.load.name_field" = "Load name";
@@ -26,89 +179,148 @@
"editor.system.location.optional" = "Location (optional)";
"editor.system.name_field" = "System name";
"editor.system.title" = "Edit System";
"loads.library.button" = "Library";
"loads.metric.cable" = "Cable";
"loads.metric.fuse" = "Fuse";
"loads.metric.length" = "Length";
"loads.onboarding.subtitle" = "Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.";
"loads.onboarding.title" = "Add your first component";
"loads.overview.empty.create" = "Add Load";
"loads.overview.empty.library" = "Browse Library";
"loads.overview.empty.message" = "Start by adding a load to see system insights.";
"loads.overview.header.title" = "Load Overview";
"loads.overview.metric.count" = "Loads";
"loads.overview.metric.current" = "Total Current";
"loads.overview.metric.power" = "Total Power";
"loads.overview.status.missing_details.banner" = "Finish configuring your loads";
"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations.";
"loads.overview.status.missing_details.plural" = "loads";
"loads.overview.status.missing_details.singular" = "load";
"loads.overview.status.missing_details.title" = "Missing load details";
"overview.chargers.empty.create" = "Add Charger";
"overview.chargers.empty.subtitle" = "Add shore power, DC-DC, or solar chargers to understand your charging capacity.";
"overview.chargers.empty.title" = "No chargers configured yet";
"overview.chargers.header.title" = "Charger Overview";
"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system.";
"overview.loads.empty.title" = "No loads configured yet";
"overview.runtime.subtitle" = "At maximum load draw";
"overview.runtime.title" = "Estimated runtime";
"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime.";
"overview.system.header.title" = "System Overview";
"overview.bom.title" = "Bill of Materials";
"overview.bom.subtitle" = "Tap to review components";
"overview.bom.unavailable" = "Add loads to generate components.";
"overview.bom.placeholder.short" = "Add loads";
"overview.chargetime.title" = "Estimated charge time";
"overview.chargetime.subtitle" = "At combined charge rate";
"overview.chargetime.unavailable" = "Add chargers and battery capacity to estimate.";
"overview.chargetime.placeholder.short" = "Add chargers";
"overview.goal.prefix" = "Goal";
"overview.goal.label" = "Goal %@";
"overview.goal.clear" = "Remove Goal";
"overview.goal.cancel" = "Cancel";
"overview.goal.save" = "Save";
"overview.runtime.goal.title" = "Runtime Goal";
"overview.runtime.placeholder.short" = "Add capacity";
"overview.chargetime.goal.title" = "Charge Goal";
"sample.battery.rv.name" = "LiFePO4 house bank";
"sample.battery.workshop.name" = "Workbench backup battery";
"sample.charger.dcdc.name" = "DC-DC charger";
"sample.charger.shore.name" = "Shore power charger";
"sample.charger.workbench.name" = "Workbench charger";
"sample.load.charger.name" = "Tool charger";
"sample.load.compressor.name" = "Air compressor";
"sample.load.fridge.name" = "Compressor fridge";
"sample.load.lighting.name" = "LED strip lighting";
"sample.system.rv.location" = "12V living circuit";
"sample.system.rv.name" = "Adventure Van";
"sample.system.workshop.location" = "Tool corner";
"sample.system.workshop.name" = "Workshop Bench";
"slider.button.ampere" = "Ampere";
"slider.button.watt" = "Watt";
"slider.current.title" = "Current";
"slider.length.title" = "Cable Length (%@)";
"slider.power.title" = "Power";
"slider.voltage.title" = "Voltage";
"system.list.no.components" = "No components yet";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metric (mm², m)";
"sample.system.rv.name" = "Adventure Van";
"sample.system.rv.location" = "12V living circuit";
"sample.system.workshop.name" = "Workshop Bench";
"sample.system.workshop.location" = "Tool corner";
"sample.load.fridge.name" = "Compressor fridge";
"sample.load.lighting.name" = "LED strip lighting";
"sample.load.compressor.name" = "Air compressor";
"sample.load.charger.name" = "Tool charger";
"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach";
"system.icon.keywords.truck" = "truck, trailer, rig";
"system.icon.keywords.boat" = "boat, marine, yacht, sail";
"system.icon.keywords.plane" = "plane, air, flight";
"system.icon.keywords.ferry" = "ferry, ship";
"system.icon.keywords.house" = "house, home, cabin, cottage, lodge";
"system.icon.keywords.building" = "building, office, warehouse, factory, facility";
"system.icon.keywords.tent" = "camp, tent, outdoor";
"system.icon.keywords.solar" = "solar, sun";
"system.icon.keywords.battery" = "battery, storage";
"system.icon.keywords.server" = "server, data, network, rack";
"system.icon.keywords.computer" = "computer, electronics, lab, tech";
"system.icon.keywords.gear" = "gear, mechanic, machine, workshop";
"system.icon.keywords.tool" = "tool, maintenance, repair, shop";
"system.icon.keywords.hammer" = "hammer, carpentry";
"system.icon.keywords.light" = "light, lighting, lamp";
"system.icon.keywords.boat" = "boat, marine, yacht, sail";
"system.icon.keywords.bolt" = "bolt, power, electric";
"system.icon.keywords.plug" = "plug";
"system.icon.keywords.engine" = "engine, generator, motor";
"system.icon.keywords.fuel" = "fuel, diesel, gas";
"system.icon.keywords.water" = "water, pump, tank";
"system.icon.keywords.heat" = "heat, heater, furnace";
"system.icon.keywords.cold" = "cold, freeze, cool";
"system.icon.keywords.building" = "building, office, warehouse, factory, facility";
"system.icon.keywords.climate" = "climate, hvac, temperature";
"tab.components" = "Components";
"system.icon.keywords.cold" = "cold, freeze, cool";
"system.icon.keywords.computer" = "computer, electronics, lab, tech";
"system.icon.keywords.engine" = "engine, generator, motor";
"system.icon.keywords.ferry" = "ferry, ship";
"system.icon.keywords.fuel" = "fuel, diesel, gas";
"system.icon.keywords.gear" = "gear, mechanic, machine, workshop";
"system.icon.keywords.hammer" = "hammer, carpentry";
"system.icon.keywords.heat" = "heat, heater, furnace";
"system.icon.keywords.house" = "house, home, cabin, cottage, lodge";
"system.icon.keywords.light" = "light, lighting, lamp";
"system.icon.keywords.plane" = "plane, air, flight";
"system.icon.keywords.plug" = "plug";
"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach";
"system.icon.keywords.server" = "server, data, network, rack";
"system.icon.keywords.solar" = "solar, sun";
"system.icon.keywords.tent" = "camp, tent, outdoor";
"system.icon.keywords.tool" = "tool, maintenance, repair, shop";
"system.icon.keywords.truck" = "truck, trailer, rig";
"system.icon.keywords.water" = "water, pump, tank";
"system.list.no.components" = "No components yet";
"tab.batteries" = "Batteries";
"tab.chargers" = "Chargers";
"battery.bank.header.title" = "Battery Bank";
"battery.bank.metric.count" = "Batteries";
"battery.bank.metric.capacity" = "Capacity";
"battery.bank.metric.energy" = "Energy";
"battery.bank.badge.voltage" = "Voltage";
"battery.bank.badge.capacity" = "Capacity";
"battery.bank.badge.energy" = "Energy";
"battery.bank.banner.voltage" = "Voltage mismatch detected";
"battery.bank.banner.capacity" = "Capacity mismatch detected";
"battery.bank.empty.title" = "No Batteries Yet";
"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@.";
"battery.bank.status.dismiss" = "Got it";
"battery.bank.status.single.battery" = "One battery";
"battery.bank.status.multiple.batteries" = "%d batteries";
"battery.bank.status.voltage.title" = "Voltage mismatch";
"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.";
"battery.bank.status.capacity.title" = "Capacity mismatch";
"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.";
"battery.editor.title" = "Battery Setup";
"battery.editor.cancel" = "Cancel";
"battery.editor.save" = "Save";
"battery.editor.field.name" = "Name";
"battery.editor.placeholder.name" = "House Bank";
"battery.editor.field.chemistry" = "Chemistry";
"battery.editor.section.summary" = "Summary";
"battery.editor.slider.voltage" = "Nominal Voltage";
"battery.editor.slider.capacity" = "Capacity";
"battery.editor.alert.voltage.title" = "Edit Nominal Voltage";
"battery.editor.alert.voltage.placeholder" = "Voltage";
"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)";
"battery.editor.alert.capacity.title" = "Edit Capacity";
"battery.editor.alert.capacity.placeholder" = "Capacity";
"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)";
"battery.editor.alert.cancel" = "Cancel";
"battery.editor.alert.save" = "Save";
"battery.editor.default_name" = "New Battery";
"chargers.title" = "Chargers for %@";
"chargers.subtitle" = "Charger components will be available soon.";
"tab.components" = "Components";
"tab.overview" = "Overview";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metric (mm², m)";
"settings.pro.cta.description" = "Cable PRO keeps advanced calculations and early tools available.";
"settings.pro.cta.button" = "Get Cable PRO";
"settings.pro.renewal.date" = "Renews on %@.";
"settings.pro.trial.remaining" = "%@ remaining in free trial.";
"settings.pro.trial.today" = "Free trial renews today.";
"settings.pro.instructions" = "Manage or cancel your subscription in the App Store.";
"settings.pro.manage.button" = "Manage Subscription";
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
"settings.pro.day.one" = "%@ day";
"settings.pro.day.other" = "%@ days";
"cable.pro.terms.label" = "Terms";
"cable.pro.privacy.label" = "Privacy";
"cable.pro.terms.url" = "https://voltplan.app/terms";
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
"cable.pro.paywall.title" = "Cable PRO";
"cable.pro.paywall.subtitle" = "Cable PRO enables more configuration options for loads, batteries and chargers.";
"cable.pro.feature.dutyCycle" = "Duty-cycle aware cable calculators";
"cable.pro.feature.batteryCapacity" = "Configure usable battery capacity";
"cable.pro.feature.usageBased" = "Usage based calculations";
"cable.pro.button.unlock" = "Unlock Now";
"cable.pro.button.freeTrial" = "Start Free Trial";
"cable.pro.button.unlocked" = "Unlocked";
"cable.pro.restore.button" = "Restore Purchases";
"cable.pro.alert.success.title" = "Cable PRO Unlocked";
"cable.pro.alert.success.body" = "Thanks for supporting Cable PRO!";
"cable.pro.alert.pending.title" = "Purchase Pending";
"cable.pro.alert.pending.body" = "Your purchase is awaiting approval.";
"cable.pro.alert.restored.title" = "Purchases Restored";
"cable.pro.alert.restored.body" = "Your purchases are available again.";
"cable.pro.alert.error.title" = "Purchase Failed";
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
"generic.ok" = "OK";
"cable.pro.trial.badge" = "Includes a %@ free trial";
"cable.pro.subscription.renews" = "Renews %@.";
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
"cable.pro.duration.day.singular" = "every day";
"cable.pro.duration.day.plural" = "every %@ days";
"cable.pro.duration.week.singular" = "every week";
"cable.pro.duration.week.plural" = "every %@ weeks";
"cable.pro.duration.month.singular" = "every month";
"cable.pro.duration.month.plural" = "every %@ months";
"cable.pro.duration.year.singular" = "every year";
"cable.pro.duration.year.plural" = "every %@ years";
"cable.pro.trial.duration.day.singular" = "%@-day";
"cable.pro.trial.duration.day.plural" = "%@-day";
"cable.pro.trial.duration.week.singular" = "%@-week";
"cable.pro.trial.duration.week.plural" = "%@-week";
"cable.pro.trial.duration.month.singular" = "%@-month";
"cable.pro.trial.duration.month.plural" = "%@-month";
"cable.pro.trial.duration.year.singular" = "%@-year";
"cable.pro.trial.duration.year.plural" = "%@-year";

View File

@@ -1,6 +1,7 @@
import SwiftUI
struct BatteriesView: View {
@Binding var editMode: EditMode
let system: ElectricalSystem
let batteries: [SavedBattery]
let onEdit: (SavedBattery) -> Void
@@ -85,6 +86,14 @@ struct BatteriesView: View {
)
}
private var metricUsableCapacityLabel: String {
String(
localized: "battery.bank.metric.usable_capacity",
bundle: .main,
comment: "Label for usable capacity metric"
)
}
private var badgeVoltageLabel: String {
String(
localized: "battery.bank.badge.voltage",
@@ -109,6 +118,14 @@ struct BatteriesView: View {
)
}
private var badgeUsableCapacityLabel: String {
String(
localized: "battery.bank.metric.usable_capacity",
bundle: .main,
comment: "Label for usable capacity badge"
)
}
private var emptyTitle: String {
String(
localized: "battery.bank.empty.title",
@@ -120,6 +137,7 @@ struct BatteriesView: View {
init(
system: ElectricalSystem,
batteries: [SavedBattery],
editMode: Binding<EditMode> = .constant(.inactive),
onEdit: @escaping (SavedBattery) -> Void = { _ in },
onDelete: @escaping (IndexSet) -> Void = { _ in }
) {
@@ -127,6 +145,7 @@ struct BatteriesView: View {
self.batteries = batteries
self.onEdit = onEdit
self.onDelete = onDelete
self._editMode = editMode
}
var body: some View {
@@ -135,24 +154,7 @@ struct BatteriesView: View {
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
summarySection
List {
ForEach(batteries) { battery in
Button {
onEdit(battery)
} label: {
batteryRow(for: battery)
}
.buttonStyle(.plain)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
batteriesListWithHeader
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -176,61 +178,30 @@ struct BatteriesView: View {
}
}
private var summarySection: some View {
private var batteryStatsHeader: some View {
StatsHeaderContainer {
batterySummaryContent
}
}
private var batterySummaryContent: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(bankTitle)
.font(.headline.weight(.semibold))
Spacer()
Text(system.name)
.font(.subheadline)
.foregroundStyle(.secondary)
}
ViewThatFits(in: .horizontal) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
summaryMetric(
icon: "battery.100",
label: metricCountLabel,
value: "\(batteries.count)",
tint: .blue
)
summaryMetric(
icon: "gauge.medium",
label: metricCapacityLabel,
value: formattedValue(totalCapacity, unit: "Ah"),
tint: .orange
)
summaryMetric(
icon: "bolt.circle",
label: metricEnergyLabel,
value: formattedValue(totalEnergy, unit: "Wh"),
tint: .green
)
}
VStack(alignment: .leading, spacing: 12) {
summaryMetric(
icon: "battery.100",
label: metricCountLabel,
value: "\(batteries.count)",
tint: .blue
)
summaryMetric(
icon: "gauge.medium",
label: metricCapacityLabel,
value: formattedValue(totalCapacity, unit: "Ah"),
tint: .orange
)
summaryMetric(
icon: "bolt.circle",
label: metricEnergyLabel,
value: formattedValue(totalEnergy, unit: "Wh"),
tint: .green
)
ForEach(Array(summaryMetrics.enumerated()), id: \.offset) { _, metric in
summaryMetric(icon: metric.icon, label: metric.label, value: metric.value, tint: metric.tint)
}
}
.padding(.horizontal, 2)
}
.scrollClipDisabled(false)
if let status = bankStatus {
Button {
@@ -241,19 +212,51 @@ struct BatteriesView: View {
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color(.systemGroupedBackground))
Divider()
.background(Color(.separator))
}
}
@ViewBuilder
private var batteriesListWithHeader: some View {
if #available(iOS 26.0, *) {
baseBatteriesList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
batteryStatsHeader
}
} else {
baseBatteriesList
.safeAreaInset(edge: .top, spacing: 0) {
batteryStatsHeader
}
}
}
private var baseBatteriesList: some View {
List {
ForEach(batteries) { battery in
Button {
onEdit(battery)
} label: {
batteryRow(for: battery)
}
.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)
}
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) {
@@ -283,24 +286,7 @@ struct BatteriesView: View {
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
metricBadge(
label: badgeVoltageLabel,
value: formattedValue(battery.nominalVoltage, unit: "V"),
tint: .orange
)
metricBadge(
label: badgeCapacityLabel,
value: formattedValue(battery.capacityAmpHours, unit: "Ah"),
tint: .blue
)
metricBadge(
label: badgeEnergyLabel,
value: formattedValue(battery.energyWattHours, unit: "Wh"),
tint: .green
)
Spacer()
}
batteryMetricsScroll(for: battery)
}
.padding(.vertical, 16)
.padding(.horizontal, 16)
@@ -310,12 +296,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)
}
@@ -333,37 +319,90 @@ 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)
private var totalUsableCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.usableCapacityAmpHours
}
}
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)
private var totalUsableCapacityShare: Double {
guard totalCapacity > 0 else { return 0 }
return max(0, min(1, totalUsableCapacity / totalCapacity))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(tint.opacity(0.12))
private func usableFraction(for battery: SavedBattery) -> Double {
guard battery.capacityAmpHours > 0 else { return 0 }
return max(0, min(1, battery.usableCapacityAmpHours / battery.capacityAmpHours))
}
private func usableCapacityDisplay(for battery: SavedBattery) -> String {
let fraction = usableFraction(for: battery)
return "\(formattedValue(battery.usableCapacityAmpHours, unit: "Ah")) (\(formattedPercentage(fraction)))"
}
private func summaryMetric(icon: String, label: String, value: String, tint: Color) -> some View {
ComponentSummaryMetricView(
icon: icon,
label: label,
value: value,
tint: tint
)
}
private var summaryMetrics: [(icon: String, label: String, value: String, tint: Color)] {
[
(
icon: "battery.100",
label: metricCountLabel,
value: "\(batteries.count)",
tint: .blue
),
(
icon: "gauge.medium",
label: metricCapacityLabel,
value: formattedValue(totalCapacity, unit: "Ah"),
tint: .orange
),
(
icon: "battery.100.bolt",
label: metricUsableCapacityLabel,
value: "\(formattedValue(totalUsableCapacity, unit: "Ah")) (\(formattedPercentage(totalUsableCapacityShare)))",
tint: .purple
),
(
icon: "bolt.circle",
label: metricEnergyLabel,
value: formattedValue(totalEnergy, unit: "Wh"),
tint: .green
)
]
}
@ViewBuilder
private func batteryMetricsScroll(for battery: SavedBattery) -> some View {
let badges: [(String, String, Color)] = [
(badgeVoltageLabel, formattedValue(battery.nominalVoltage, unit: "V"), .orange),
(badgeCapacityLabel, formattedValue(battery.capacityAmpHours, unit: "Ah"), .blue),
(badgeUsableCapacityLabel, usableCapacityDisplay(for: battery), .purple),
(badgeEnergyLabel, formattedValue(battery.energyWattHours, unit: "Wh"), .green)
]
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Array(badges.enumerated()), id: \.offset) { _, badge in
ComponentMetricBadgeView(label: badge.0, value: badge.1, tint: badge.2)
}
}
.padding(.horizontal, 2)
}
.scrollClipDisabled(false)
}
private func metricBadge(label: String, value: String, tint: Color) -> some View {
ComponentMetricBadgeView(
label: label,
value: value,
tint: tint
)
}
@@ -385,25 +424,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")
@@ -435,6 +455,13 @@ struct BatteriesView: View {
return "\(numberString) \(unit)"
}
private func formattedPercentage(_ fraction: Double) -> String {
let clamped = max(0, min(1, fraction))
let percent = clamped * 100
let numberString = Self.numberFormatter.string(from: NSNumber(value: percent)) ?? String(format: "%.1f", percent)
return "\(numberString) %"
}
private var dominantVoltage: Double? {
guard batteries.count > 1 else { return nil }
return dominantValue(
@@ -587,6 +614,8 @@ private enum BatteriesViewPreviewData {
nominalVoltage: 12.8,
capacityAmpHours: 200,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100.bolt",
colorName: "green",
system: system
),
SavedBattery(
@@ -594,6 +623,8 @@ private enum BatteriesViewPreviewData {
nominalVoltage: 12.0,
capacityAmpHours: 90,
chemistry: .agm,
iconName: "bolt",
colorName: "orange",
system: system
)
]
@@ -602,6 +633,7 @@ private enum BatteriesViewPreviewData {
#Preview {
BatteriesView(
system: BatteriesViewPreviewData.system,
batteries: BatteriesViewPreviewData.batteries
batteries: BatteriesViewPreviewData.batteries,
editMode: .constant(.inactive)
)
}

View File

@@ -0,0 +1,174 @@
import Foundation
import SwiftData
struct BatteryConfiguration: Identifiable, Hashable {
enum Chemistry: String, CaseIterable, Identifiable {
case agm = "AGM"
case gel = "Gel"
case floodedLeadAcid = "Flooded Lead Acid"
case lithiumIronPhosphate = "LiFePO4"
case lithiumIon = "Lithium Ion"
var id: Self { self }
var displayName: String {
rawValue
}
var usableCapacityFraction: Double {
switch self {
case .floodedLeadAcid:
return 0.5
case .agm:
return 0.5
case .gel:
return 0.6
case .lithiumIronPhosphate:
return 0.9
case .lithiumIon:
return 0.85
}
}
}
let id: UUID
var name: String
var nominalVoltage: Double
var capacityAmpHours: Double
var usableCapacityOverrideFraction: Double?
var chargeVoltage: Double
var cutOffVoltage: Double
var minimumTemperatureCelsius: Double
var maximumTemperatureCelsius: Double
var chemistry: Chemistry
var iconName: String
var colorName: String
var system: ElectricalSystem
init(
id: UUID = UUID(),
name: String,
nominalVoltage: Double = 12.8,
capacityAmpHours: Double = 100,
chemistry: Chemistry = .lithiumIronPhosphate,
usableCapacityOverrideFraction: Double? = nil,
chargeVoltage: Double = 14.4,
cutOffVoltage: Double = 10.8,
minimumTemperatureCelsius: Double = -20,
maximumTemperatureCelsius: Double = 60,
iconName: String = "battery.100",
colorName: String = "blue",
system: ElectricalSystem
) {
self.id = id
self.name = name
self.nominalVoltage = nominalVoltage
self.capacityAmpHours = capacityAmpHours
self.usableCapacityOverrideFraction = usableCapacityOverrideFraction
self.chargeVoltage = chargeVoltage
self.cutOffVoltage = cutOffVoltage
self.minimumTemperatureCelsius = minimumTemperatureCelsius
self.maximumTemperatureCelsius = maximumTemperatureCelsius
self.chemistry = chemistry
self.iconName = iconName
self.colorName = colorName
self.system = system
}
init(savedBattery: SavedBattery, system: ElectricalSystem) {
self.id = savedBattery.id
self.name = savedBattery.name
self.nominalVoltage = savedBattery.nominalVoltage
self.capacityAmpHours = savedBattery.capacityAmpHours
self.usableCapacityOverrideFraction = savedBattery.usableCapacityOverrideFraction
self.chargeVoltage = savedBattery.chargeVoltage ?? 14.4
self.cutOffVoltage = savedBattery.cutOffVoltage ?? 10.8
self.minimumTemperatureCelsius = savedBattery.minimumTemperatureCelsius ?? -20
self.maximumTemperatureCelsius = savedBattery.maximumTemperatureCelsius ?? 60
if self.maximumTemperatureCelsius < self.minimumTemperatureCelsius {
let correctedMin = min(self.minimumTemperatureCelsius, self.maximumTemperatureCelsius)
let correctedMax = max(self.minimumTemperatureCelsius, self.maximumTemperatureCelsius)
self.minimumTemperatureCelsius = correctedMin
self.maximumTemperatureCelsius = correctedMax
}
self.chemistry = savedBattery.chemistry
self.iconName = savedBattery.iconName
self.colorName = savedBattery.colorName
self.system = system
}
var energyWattHours: Double {
nominalVoltage * capacityAmpHours
}
var defaultUsableCapacityFraction: Double {
chemistry.usableCapacityFraction
}
var usableCapacityFraction: Double {
if let override = usableCapacityOverrideFraction {
return max(0, min(1, override))
}
return defaultUsableCapacityFraction
}
var defaultUsableCapacityAmpHours: Double {
capacityAmpHours * defaultUsableCapacityFraction
}
var usableCapacityAmpHours: Double {
capacityAmpHours * usableCapacityFraction
}
var usableEnergyWattHours: Double {
usableCapacityAmpHours * nominalVoltage
}
func apply(to savedBattery: SavedBattery) {
savedBattery.name = name
savedBattery.nominalVoltage = nominalVoltage
savedBattery.capacityAmpHours = capacityAmpHours
savedBattery.usableCapacityOverrideFraction = usableCapacityOverrideFraction
savedBattery.chargeVoltage = chargeVoltage
savedBattery.cutOffVoltage = cutOffVoltage
savedBattery.minimumTemperatureCelsius = minimumTemperatureCelsius
savedBattery.maximumTemperatureCelsius = maximumTemperatureCelsius
savedBattery.chemistry = chemistry
savedBattery.iconName = iconName
savedBattery.colorName = colorName
savedBattery.system = system
savedBattery.timestamp = Date()
}
}
extension BatteryConfiguration {
static func == (lhs: BatteryConfiguration, rhs: BatteryConfiguration) -> Bool {
lhs.id == rhs.id &&
lhs.name == rhs.name &&
lhs.nominalVoltage == rhs.nominalVoltage &&
lhs.capacityAmpHours == rhs.capacityAmpHours &&
lhs.usableCapacityOverrideFraction == rhs.usableCapacityOverrideFraction &&
lhs.chargeVoltage == rhs.chargeVoltage &&
lhs.cutOffVoltage == rhs.cutOffVoltage &&
lhs.minimumTemperatureCelsius == rhs.minimumTemperatureCelsius &&
lhs.maximumTemperatureCelsius == rhs.maximumTemperatureCelsius &&
lhs.chemistry == rhs.chemistry &&
lhs.iconName == rhs.iconName &&
lhs.colorName == rhs.colorName
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
hasher.combine(name)
hasher.combine(nominalVoltage)
hasher.combine(capacityAmpHours)
hasher.combine(usableCapacityOverrideFraction)
hasher.combine(chargeVoltage)
hasher.combine(cutOffVoltage)
hasher.combine(minimumTemperatureCelsius)
hasher.combine(maximumTemperatureCelsius)
hasher.combine(chemistry)
hasher.combine(iconName)
hasher.combine(colorName)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +0,0 @@
import Foundation
import SwiftData
struct BatteryConfiguration: Identifiable {
enum Chemistry: String, CaseIterable, Identifiable {
case agm = "AGM"
case gel = "Gel"
case floodedLeadAcid = "Flooded Lead Acid"
case lithiumIronPhosphate = "LiFePO4"
case lithiumIon = "Lithium Ion"
var id: Self { self }
var displayName: String {
rawValue
}
}
let id: UUID
var name: String
var nominalVoltage: Double
var capacityAmpHours: Double
var chemistry: Chemistry
var system: ElectricalSystem
init(
id: UUID = UUID(),
name: String,
nominalVoltage: Double = 12.8,
capacityAmpHours: Double = 100,
chemistry: Chemistry = .lithiumIronPhosphate,
system: ElectricalSystem
) {
self.id = id
self.name = name
self.nominalVoltage = nominalVoltage
self.capacityAmpHours = capacityAmpHours
self.chemistry = chemistry
self.system = system
}
init(savedBattery: SavedBattery, system: ElectricalSystem) {
self.id = savedBattery.id
self.name = savedBattery.name
self.nominalVoltage = savedBattery.nominalVoltage
self.capacityAmpHours = savedBattery.capacityAmpHours
self.chemistry = savedBattery.chemistry
self.system = system
}
var energyWattHours: Double {
nominalVoltage * capacityAmpHours
}
func apply(to savedBattery: SavedBattery) {
savedBattery.name = name
savedBattery.nominalVoltage = nominalVoltage
savedBattery.capacityAmpHours = capacityAmpHours
savedBattery.chemistry = chemistry
savedBattery.system = system
savedBattery.timestamp = Date()
}
}

View File

@@ -1,478 +0,0 @@
import SwiftUI
struct BatteryEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var configuration: BatteryConfiguration
@State private var editingField: EditingField?
let onSave: (BatteryConfiguration) -> Void
let onCancel: () -> Void
private enum EditingField {
case voltage
case capacity
}
private let voltageSnapValues: [Double] = [6, 12, 12.8, 24, 25.6, 36, 48, 51.2]
private let capacitySnapValues: [Double] = [10, 20, 50, 75, 100, 125, 150, 200, 300, 400, 600, 800, 1000]
private let voltageSnapTolerance: Double = 0.5
private let capacitySnapTolerance: Double = 10.0
private var nameFieldLabel: String {
String(
localized: "battery.editor.field.name",
bundle: .main,
comment: "Label for the battery name text field"
)
}
private var namePlaceholder: String {
String(
localized: "battery.editor.placeholder.name",
bundle: .main,
comment: "Placeholder example for the battery name field"
)
}
private var chemistryLabel: String {
String(
localized: "battery.editor.field.chemistry",
bundle: .main,
comment: "Label describing the chemistry menu"
)
}
private var summaryLabel: String {
String(
localized: "battery.editor.section.summary",
bundle: .main,
comment: "Label for the summary section in the editor"
)
}
private var sliderVoltageTitle: String {
String(
localized: "battery.editor.slider.voltage",
bundle: .main,
comment: "Title for the nominal voltage slider"
)
}
private var sliderCapacityTitle: String {
String(
localized: "battery.editor.slider.capacity",
bundle: .main,
comment: "Title for the capacity slider"
)
}
private var summaryVoltageLabel: String {
String(
localized: "battery.bank.badge.voltage",
bundle: .main,
comment: "Label used for voltage values"
)
}
private var summaryCapacityLabel: String {
String(
localized: "battery.bank.badge.capacity",
bundle: .main,
comment: "Label used for capacity values"
)
}
private var summaryEnergyLabel: String {
String(
localized: "battery.bank.badge.energy",
bundle: .main,
comment: "Label used for energy values"
)
}
init(configuration: BatteryConfiguration, onSave: @escaping (BatteryConfiguration) -> Void, onCancel: @escaping () -> Void) {
_configuration = State(initialValue: configuration)
self.onSave = onSave
self.onCancel = onCancel
}
var body: some View {
ScrollView {
VStack(spacing: 24) {
headerCard
slidersSection
}
.padding(.vertical, 24)
.padding(.horizontal)
}
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle(
NSLocalizedString(
"battery.editor.title",
bundle: .main,
value: "Battery Setup",
comment: "Title for the battery editor"
)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(
NSLocalizedString(
"battery.editor.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title"
)
) {
cancel()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(
NSLocalizedString(
"battery.editor.save",
bundle: .main,
value: "Save",
comment: "Save button title"
)
) {
save()
}
.disabled(configuration.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.alert(
NSLocalizedString(
"battery.editor.alert.voltage.title",
bundle: .main,
value: "Edit Nominal Voltage",
comment: "Title for the voltage edit alert"
),
isPresented: Binding(
get: { editingField == .voltage },
set: { if !$0 { editingField = nil } }
)
) {
TextField(
NSLocalizedString(
"battery.editor.alert.voltage.placeholder",
bundle: .main,
value: "Voltage",
comment: "Placeholder for voltage text field"
),
value: $configuration.nominalVoltage,
format: .number
)
.keyboardType(.decimalPad)
Button(
NSLocalizedString(
"battery.editor.alert.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title for edit alerts"
),
role: .cancel
) { editingField = nil }
Button(
NSLocalizedString(
"battery.editor.alert.save",
bundle: .main,
value: "Save",
comment: "Save button title for edit alerts"
)
) {
editingField = nil
let normalized = normalizedVoltage(for: configuration.nominalVoltage)
if abs(normalized - configuration.nominalVoltage) > 0.000001 {
configuration.nominalVoltage = normalized
}
}
} message: {
Text(
NSLocalizedString(
"battery.editor.alert.voltage.message",
bundle: .main,
value: "Enter voltage in volts (V)",
comment: "Message for the voltage edit alert"
)
)
}
.alert(
NSLocalizedString(
"battery.editor.alert.capacity.title",
bundle: .main,
value: "Edit Capacity",
comment: "Title for the capacity edit alert"
),
isPresented: Binding(
get: { editingField == .capacity },
set: { if !$0 { editingField = nil } }
)
) {
TextField(
NSLocalizedString(
"battery.editor.alert.capacity.placeholder",
bundle: .main,
value: "Capacity",
comment: "Placeholder for capacity text field"
),
value: $configuration.capacityAmpHours,
format: .number
)
.keyboardType(.decimalPad)
Button(
NSLocalizedString(
"battery.editor.alert.cancel",
bundle: .main,
value: "Cancel",
comment: "Cancel button title for edit alerts"
),
role: .cancel
) { editingField = nil }
Button(
NSLocalizedString(
"battery.editor.alert.save",
bundle: .main,
value: "Save",
comment: "Save button title for edit alerts"
)
) {
editingField = nil
let normalized = normalizedCapacity(for: configuration.capacityAmpHours)
if abs(normalized - configuration.capacityAmpHours) > 0.000001 {
configuration.capacityAmpHours = normalized
}
}
} message: {
Text(
NSLocalizedString(
"battery.editor.alert.capacity.message",
bundle: .main,
value: "Enter capacity in amp-hours (Ah)",
comment: "Message for the capacity edit alert"
)
)
}
}
private var headerCard: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text(nameFieldLabel)
.font(.caption)
.foregroundStyle(.secondary)
TextField(namePlaceholder, text: $configuration.name)
.textInputAutocapitalization(.words)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
VStack(alignment: .leading, spacing: 8) {
Text(chemistryLabel)
.font(.caption)
.foregroundStyle(.secondary)
Menu {
ForEach(BatteryConfiguration.Chemistry.allCases) { chemistry in
Button {
configuration.chemistry = chemistry
} label: {
if chemistry == configuration.chemistry {
Label(chemistry.displayName, systemImage: "checkmark")
} else {
Text(chemistry.displayName)
}
}
}
} label: {
HStack {
Text(configuration.chemistry.displayName)
.font(.body.weight(.semibold))
Spacer()
Image(systemName: "chevron.down")
.font(.footnote.weight(.bold))
.foregroundStyle(.secondary)
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
.buttonStyle(.plain)
}
VStack(alignment: .leading, spacing: 6) {
Text(summaryLabel)
.font(.caption)
.foregroundStyle(.secondary)
ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
summaryBadge(
title: summaryVoltageLabel,
value: formattedValue(configuration.nominalVoltage, unit: "V"),
symbol: "bolt"
)
summaryBadge(
title: summaryCapacityLabel,
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
symbol: "gauge.medium"
)
summaryBadge(
title: summaryEnergyLabel,
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
symbol: "battery.100.bolt"
)
}
VStack(spacing: 12) {
summaryBadge(
title: summaryVoltageLabel,
value: formattedValue(configuration.nominalVoltage, unit: "V"),
symbol: "bolt"
)
summaryBadge(
title: summaryCapacityLabel,
value: formattedValue(configuration.capacityAmpHours, unit: "Ah"),
symbol: "gauge.medium"
)
summaryBadge(
title: summaryEnergyLabel,
value: formattedValue(configuration.energyWattHours, unit: "Wh"),
symbol: "battery.100.bolt"
)
}
}
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
private var slidersSection: some View {
VStack(spacing: 30) {
SliderSection(
title: sliderVoltageTitle,
value: $configuration.nominalVoltage,
range: 6...60,
unit: "V",
tapAction: { editingField = .voltage },
snapValues: voltageSnapValues
)
.onChange(of: configuration.nominalVoltage) { _, newValue in
let normalized = normalizedVoltage(for: newValue)
if abs(normalized - newValue) > 0.000001 {
configuration.nominalVoltage = normalized
}
}
SliderSection(
title: sliderCapacityTitle,
value: $configuration.capacityAmpHours,
range: 5...1000,
unit: "Ah",
tapAction: { editingField = .capacity },
snapValues: capacitySnapValues
)
.onChange(of: configuration.capacityAmpHours) { _, newValue in
let normalized = normalizedCapacity(for: newValue)
if abs(normalized - newValue) > 0.000001 {
configuration.capacityAmpHours = normalized
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
private func normalizedVoltage(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: voltageSnapValues, tolerance: voltageSnapTolerance) {
return snapped
}
return rounded
}
private func normalizedCapacity(for value: Double) -> Double {
let rounded = (value * 10).rounded() / 10
if let snapped = nearestValue(to: rounded, in: capacitySnapValues, tolerance: capacitySnapTolerance) {
return snapped
}
return rounded
}
private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? {
guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
return abs(closest - value) <= tolerance ? closest : nil
}
private func summaryBadge(title: String, value: String, symbol: String) -> some View {
VStack(spacing: 4) {
Image(systemName: symbol)
.font(.title3)
.foregroundStyle(Color.accentColor)
Text(value)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.8)
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
}
private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}()
private func formattedValue(_ value: Double, unit: String) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) \(unit)"
}
private func save() {
onSave(configuration)
dismiss()
}
private func cancel() {
onCancel()
dismiss()
}
}
#Preview {
let previewSystem = ElectricalSystem(name: "Camper")
return NavigationStack {
BatteryEditorView(
configuration: BatteryConfiguration(name: "House Bank", system: previewSystem),
onSave: { _ in },
onCancel: {}
)
}
}

View File

@@ -10,18 +10,20 @@ import SwiftData
@main
struct CableApp: App {
@StateObject private var unitSettings = UnitSystemSettings()
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var unitSettings: UnitSystemSettings
@StateObject private var storeKitManager: StoreKitManager
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 {
@@ -31,8 +33,11 @@ struct CableApp: App {
}()
init() {
let unitSettings = UnitSystemSettings()
_unitSettings = StateObject(wrappedValue: unitSettings)
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
#if DEBUG
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
#endif
}
@@ -40,6 +45,7 @@ struct CableApp: App {
WindowGroup {
ContentView()
.environmentObject(unitSettings)
.environmentObject(storeKitManager)
}
.modelContainer(sharedModelContainer)
}

View File

@@ -1,160 +0,0 @@
//
// CableCalculator.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import Foundation
import SwiftData
class CableCalculator: ObservableObject {
@Published var voltage: Double = 12.0
@Published var current: Double = 5.0
@Published var power: Double = 60.0
@Published var length: Double = 10.0
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
var calculatedPower: Double {
voltage * current
}
var calculatedCurrent: Double {
voltage > 0 ? power / voltage : 0
}
func updateFromCurrent() {
power = voltage * current
}
func updateFromPower() {
current = voltage > 0 ? power / voltage : 0
}
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048 // ft to m
// Simplified calculation: minimum cross-section based on current and voltage drop
let maxVoltageDrop = voltage * 0.05 // 5% voltage drop limit
let resistivity = 0.017 // Copper resistivity at 20°C (Ωmm²/m)
let calculatedMinCrossSection = (2 * current * lengthInMeters * resistivity) / maxVoltageDrop
if unitSystem == .imperial {
// Standard AWG wire sizes
let standardAWG = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
let awgCrossSections = [0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0]
// Find the smallest AWG that meets the requirement
for (index, crossSection) in awgCrossSections.enumerated() {
if crossSection >= calculatedMinCrossSection {
return Double(standardAWG[index])
}
}
return Double(standardAWG.last!) // Largest available
} else {
// Standard metric cable cross-sections in mm²
let standardSizes = [0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0]
// Find the smallest standard size that meets the requirement
return standardSizes.first { $0 >= max(0.75, calculatedMinCrossSection) } ?? standardSizes.last!
}
}
func crossSection(for unitSystem: UnitSystem) -> Double {
recommendedCrossSection(for: unitSystem)
}
func voltageDrop(for unitSystem: UnitSystem) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048
let crossSectionMM2 = unitSystem == .metric ? crossSection(for: unitSystem) : crossSectionFromAWG(crossSection(for: unitSystem))
let resistivity = 0.017
let effectiveCurrent = current // Always use the current property which gets updated
return (2 * effectiveCurrent * lengthInMeters * resistivity) / crossSectionMM2
}
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
(voltageDrop(for: unitSystem) / voltage) * 100
}
func powerLoss(for unitSystem: UnitSystem) -> Double {
let effectiveCurrent = current
return effectiveCurrent * voltageDrop(for: unitSystem)
}
var recommendedFuse: Int {
let targetFuse = current * 1.25 // 125% of load current for safety
// Common fuse values in amperes
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
// Find the smallest standard fuse that's >= target
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
}
// AWG conversion helper for voltage drop calculations
private func crossSectionFromAWG(_ awg: 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, 1: 42.4, 0: 53.5]
// Handle 00, 000, 0000 AWG (represented as negative values)
if awg == 00 { return 67.4 }
if awg == 000 { return 85.0 }
if awg == 0000 { return 107.0 }
return awgSizes[Int(awg)] ?? 0.75
}
}
@Model
class ElectricalSystem {
var name: String = ""
var location: String = ""
var timestamp: Date = Date()
var iconName: String = "building.2"
var colorName: String = "blue"
init(name: String, location: String = "", iconName: String = "building.2", colorName: String = "blue") {
self.name = name
self.location = location
self.timestamp = Date()
self.iconName = iconName
self.colorName = colorName
}
}
@Model
class SavedLoad {
var name: String = ""
var voltage: Double = 0.0
var current: Double = 0.0
var power: Double = 0.0
var length: Double = 0.0
var crossSection: Double = 0.0
var timestamp: Date = Date()
var iconName: String = "lightbulb"
var colorName: String = "blue"
var isWattMode: Bool = false
var system: ElectricalSystem?
var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil
var affiliateCountryCode: String? = nil
var bomCompletedItemIDs: [String] = []
var identifier: String = UUID().uuidString
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil, affiliateURLString: String? = nil, affiliateCountryCode: String? = nil, bomCompletedItemIDs: [String] = [], identifier: String = UUID().uuidString) {
self.name = name
self.voltage = voltage
self.current = current
self.power = power
self.length = length
self.crossSection = crossSection
self.timestamp = Date()
self.iconName = iconName
self.colorName = colorName
self.isWattMode = isWattMode
self.system = system
self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier
}
}

View 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()
}
}

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

View File

@@ -0,0 +1,397 @@
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 {
chargersListWithHeader
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
private var chargerStatsHeader: some View {
StatsHeaderContainer {
chargerSummaryContent
}
}
private var chargerSummaryContent: 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(.horizontal, 2)
}
.scrollClipDisabled(false)
}
}
}
@ViewBuilder
private var chargersListWithHeader: some View {
if #available(iOS 26.0, *) {
baseChargersList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
chargerStatsHeader
}
} else {
baseChargersList
.safeAreaInset(edge: .top, spacing: 0) {
chargerStatsHeader
}
}
}
private var baseChargersList: some View {
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")
}
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)
)
}

View File

@@ -0,0 +1,62 @@
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 bomCompletedItemIDs: [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,
bomCompletedItemIDs: [String] = [],
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.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier
}
var effectivePowerWatts: Double {
if maxPowerWatts > 0 {
return maxPowerWatts
}
return outputVoltage * maxCurrentAmps
}
}

View File

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

View File

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

View File

@@ -0,0 +1,168 @@
//
// CableCalculator.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 11.09.25.
//
import Foundation
import SwiftData
class CableCalculator: ObservableObject {
@Published var voltage: Double = 12.0
@Published var current: Double = 5.0
@Published var power: Double = 60.0
@Published var length: Double = 10.0
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
@Published var dutyCyclePercent: Double = 100.0
@Published var dailyUsageHours: Double = 1.0
var calculatedPower: Double {
voltage * current
}
var calculatedCurrent: Double {
voltage > 0 ? power / voltage : 0
}
func updateFromCurrent() {
power = voltage * current
}
func updateFromPower() {
current = voltage > 0 ? power / voltage : 0
}
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
ElectricalCalculations.recommendedCrossSection(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
func crossSection(for unitSystem: UnitSystem) -> Double {
recommendedCrossSection(for: unitSystem)
}
func voltageDrop(for unitSystem: UnitSystem) -> Double {
ElectricalCalculations.voltageDrop(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
ElectricalCalculations.voltageDropPercentage(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
func powerLoss(for unitSystem: UnitSystem) -> Double {
ElectricalCalculations.powerLoss(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
}
var recommendedFuse: Int {
ElectricalCalculations.recommendedFuse(forCurrent: current)
}
}
@Model
class ElectricalSystem {
var name: String = ""
var location: String = ""
var timestamp: Date = Date()
var iconName: String = "building.2"
var colorName: String = "blue"
var targetRuntimeHours: Double?
var targetChargeTimeHours: Double?
init(
name: String,
location: String = "",
iconName: String = "building.2",
colorName: String = "blue",
targetRuntimeHours: Double? = nil,
targetChargeTimeHours: Double? = nil
) {
self.name = name
self.location = location
self.timestamp = Date()
self.iconName = iconName
self.colorName = colorName
self.targetRuntimeHours = targetRuntimeHours
self.targetChargeTimeHours = targetChargeTimeHours
}
}
@Model
class SavedLoad {
var name: String = ""
var voltage: Double = 0.0
var current: Double = 0.0
var power: Double = 0.0
var length: Double = 0.0
var crossSection: Double = 0.0
var timestamp: Date = Date()
var iconName: String = "lightbulb"
var colorName: String = "blue"
var isWattMode: Bool = false
var dutyCyclePercent: Double = 100.0
var dailyUsageHours: Double = 1.0
var system: ElectricalSystem?
var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil
var affiliateCountryCode: String? = nil
var bomCompletedItemIDs: [String] = []
var identifier: String = UUID().uuidString
init(
name: String,
voltage: Double,
current: Double,
power: Double,
length: Double,
crossSection: Double,
iconName: String = "lightbulb",
colorName: String = "blue",
isWattMode: Bool = false,
dutyCyclePercent: Double = 100.0,
dailyUsageHours: Double = 1.0,
system: ElectricalSystem? = nil,
remoteIconURLString: String? = nil,
affiliateURLString: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString
) {
self.name = name
self.voltage = voltage
self.current = current
self.power = power
self.length = length
self.crossSection = crossSection
self.timestamp = Date()
self.iconName = iconName
self.colorName = colorName
self.isWattMode = isWattMode
self.dutyCyclePercent = dutyCyclePercent
self.dailyUsageHours = dailyUsageHours
self.system = system
self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,138 @@
//
// ElectricalCalculations.swift
// Cable
//
// Created by GPT on request.
//
import Foundation
struct ElectricalCalculations {
private static let maxVoltageDropFraction = 0.05
private static let copperResistivity = 0.017 // Ωmm²/m
private static let feetToMeters = 0.3048
private static let standardMetricCrossSections: [Double] = [
0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0,
150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0,
]
private static let standardAWG: [Int] = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
private static let awgCrossSections: [Double] = [
0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0,
]
private static let standardFuses: [Int] = [
1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50,
60, 70, 80, 100, 125, 150, 175, 200, 225, 250,
300, 350, 400, 450, 500, 600, 700, 800,
]
static func recommendedCrossSection(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem
) -> Double {
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
let maxVoltageDrop = voltage * maxVoltageDropFraction
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
}
if unitSystem == .imperial {
for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection {
return Double(standardAWG[index])
}
return Double(standardAWG.last ?? 0)
} else {
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) }
?? standardMetricCrossSections.last ?? 0.75
}
}
static func voltageDrop(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = nil
) -> Double {
let selectedCrossSection = crossSection ?? recommendedCrossSection(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem
)
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
let crossSectionMM2: Double
if unitSystem == .metric {
crossSectionMM2 = selectedCrossSection
} else {
crossSectionMM2 = crossSectionFromAWG(selectedCrossSection)
}
guard crossSectionMM2 > 0 else { return 0 }
return (2 * current * lengthInMeters * copperResistivity) / crossSectionMM2
}
static func voltageDropPercentage(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = nil
) -> Double {
guard voltage != 0 else { return 0 }
let drop = voltageDrop(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem,
crossSection: crossSection
)
return (drop / voltage) * 100
}
static func powerLoss(
length: Double,
current: Double,
voltage: Double,
unitSystem: UnitSystem,
crossSection: Double? = nil
) -> Double {
let drop = voltageDrop(
length: length,
current: current,
voltage: voltage,
unitSystem: unitSystem,
crossSection: crossSection
)
return current * drop
}
static func recommendedFuse(forCurrent current: Double) -> Int {
let target = Int((current * 1.25).rounded(.up))
return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target
}
private static func guardAgainstZero(_ divisor: Double, calculation: () -> Double) -> Double {
guard divisor > 0 else { return 0 }
return calculation()
}
private static func crossSectionFromAWG(_ awg: Double) -> Double {
switch awg {
case 00: return 67.4
case 000: return 85.0
case 0000: return 107.0
default:
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
if index >= 0 && index < awgCrossSections.count {
return awgCrossSections[index]
}
return 0.75
}
}
}

View File

@@ -0,0 +1,76 @@
import SwiftUI
enum LoadConfigurationStatus: Identifiable, Equatable {
case missingDetails(count: Int)
var id: String {
switch self {
case .missingDetails(let count):
return "missing-details-\(count)"
}
}
var symbol: String {
switch self {
case .missingDetails:
return "exclamationmark.triangle.fill"
}
}
var tint: Color {
switch self {
case .missingDetails:
return .orange
}
}
var bannerText: String {
switch self {
case .missingDetails:
return NSLocalizedString(
"loads.overview.status.missing_details.banner",
bundle: .main,
value: "Finish configuring your loads",
comment: "Short banner text describing loads that need additional details"
)
}
}
func detailInfo() -> LoadStatusDetail {
switch self {
case .missingDetails(let count):
let title = NSLocalizedString(
"loads.overview.status.missing_details.title",
bundle: .main,
value: "Missing load details",
comment: "Alert title when loads are missing required details"
)
let format = NSLocalizedString(
"loads.overview.status.missing_details.message",
bundle: .main,
value: "Enter cable length and wire size for %d %@ to see accurate recommendations.",
comment: "Alert message when loads are missing required details"
)
let loadWord = count == 1
? NSLocalizedString(
"loads.overview.status.missing_details.singular",
bundle: .main,
value: "load",
comment: "Singular noun for load"
)
: NSLocalizedString(
"loads.overview.status.missing_details.plural",
bundle: .main,
value: "loads",
comment: "Plural noun for loads"
)
let message = String(format: format, count, loadWord)
return LoadStatusDetail(title: title, message: message)
}
}
}
struct LoadStatusDetail {
let title: String
let message: String
}

View File

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

1003
Cable/Loads/LoadsView.swift Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
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) {
Spacer()
Button(action: onPrimaryAction) {
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("create-component-button")
.buttonStyle(.borderedProminent)
.controlSize(.large)
if let secondaryTitle = configuration.secondaryActionTitle,
let secondaryIcon = configuration.secondaryActionIcon {
Button(action: onSecondaryAction) {
Label(secondaryTitle, systemImage: secondaryIcon)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("select-component-button")
.buttonStyle(.bordered)
.tint(.accentColor)
.controlSize(.large)
}
}
.padding(.horizontal, 24)
.frame(minHeight: 140)
}
.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"
]
)
}
}

View File

@@ -1,524 +0,0 @@
//
// LoadsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@Query(sort: \SavedBattery.timestamp, order: .reverse) private var allBatteries: [SavedBattery]
@State private var newLoadToEdit: SavedLoad?
@State private var showingSystemEditor = false
@State private var hasPresentedSystemEditorOnAppear = false
@State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false
@State private var showingSystemBOM = false
@State private var selectedComponentTab: ComponentTab = .components
@State private var batteryDraft: BatteryConfiguration?
let system: ElectricalSystem
private let presentSystemEditorOnAppear: Bool
private let loadToOpenOnAppear: SavedLoad?
init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil) {
self.system = system
self.presentSystemEditorOnAppear = presentSystemEditorOnAppear
self.loadToOpenOnAppear = loadToOpenOnAppear
}
private var savedLoads: [SavedLoad] {
allLoads.filter { $0.system == system }
}
private var savedBatteries: [SavedBattery] {
allBatteries.filter { $0.system == system }
}
var body: some View {
VStack(spacing: 0) {
if savedLoads.isEmpty {
emptyStateView
} else {
TabView(selection: $selectedComponentTab) {
componentsTab
.tag(ComponentTab.components)
.tabItem {
Label(
String(
localized: "tab.components",
bundle: .main,
comment: "Tab title for components list"
),
systemImage: "square.stack.3d.up"
)
}
BatteriesView(
system: system,
batteries: savedBatteries,
onEdit: { editBattery($0) },
onDelete: deleteBatteries
)
.tag(ComponentTab.batteries)
.tabItem {
Label(
String(
localized: "tab.batteries",
bundle: .main,
comment: "Tab title for battery configurations"
),
systemImage: "battery.100"
)
}
ChargersView(system: system)
.tag(ComponentTab.chargers)
.tabItem {
Label(
String(
localized: "tab.chargers",
bundle: .main,
comment: "Tab title for chargers view"
),
systemImage: "bolt.fill"
)
}
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Button(action: {
showingSystemEditor = true
}) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(colorForName(system.colorName))
.frame(width: 24, height: 24)
Image(systemName: system.iconName)
.font(.system(size: 12))
.foregroundColor(.white)
}
Text(system.name)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
if !savedLoads.isEmpty && selectedComponentTab == .components {
Button(action: {
showingSystemBOM = true
}) {
Image(systemName: "list.bullet.rectangle")
}
.accessibilityIdentifier("system-bom-button")
}
Button(action: {
handlePrimaryAction()
}) {
Image(systemName: "plus")
}
.disabled(selectedComponentTab == .chargers)
if selectedComponentTab == .components || selectedComponentTab == .batteries {
EditButton()
}
}
}
}
.navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load)
}
.sheet(item: $batteryDraft) { draft in
NavigationStack {
BatteryEditorView(
configuration: draft,
onSave: { configuration in
saveBattery(configuration)
batteryDraft = nil
},
onCancel: {
batteryDraft = nil
}
)
}
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponent(item)
}
}
.sheet(isPresented: $showingSystemBOM) {
SystemBillOfMaterialsView(
systemName: system.name,
loads: savedLoads,
unitSystem: unitSettings.unitSystem
)
}
.sheet(isPresented: $showingSystemEditor) {
SystemEditorView(
systemName: Binding(
get: { system.name },
set: { system.name = $0 }
),
location: Binding(
get: { system.location },
set: { system.location = $0 }
),
iconName: Binding(
get: { system.iconName },
set: { system.iconName = $0 }
),
colorName: Binding(
get: { system.colorName },
set: { system.colorName = $0 }
)
)
}
.onAppear {
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async {
showingSystemEditor = true
}
}
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
hasOpenedLoadOnAppear = true
DispatchQueue.main.async {
newLoadToEdit = loadToOpen
}
}
}
}
private var librarySection: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Component Library")
.font(.headline)
.fontWeight(.semibold)
Text("Browse electrical components from VoltPlan")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
showingComponentLibrary = true
}) {
HStack(spacing: 6) {
Text("Browse")
.font(.subheadline)
.fontWeight(.medium)
Image(systemName: "arrow.up.right")
.font(.caption)
}
.foregroundColor(.blue)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemGroupedBackground))
Divider()
}
}
private var componentsTab: some View {
VStack(spacing: 0) {
librarySection
List {
ForEach(savedLoads) { load in
Button {
selectLoad(load)
} label: {
loadRow(for: load)
}
.buttonStyle(.plain)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: deleteLoads)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.accessibilityIdentifier("loads-list")
}
.background(Color(.systemGroupedBackground))
}
private func selectLoad(_ load: SavedLoad) {
newLoadToEdit = load
}
private func loadRow(for load: SavedLoad) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
LoadIconView(
remoteIconURLString: load.remoteIconURLString,
fallbackSystemName: load.iconName,
fallbackColor: colorForName(load.colorName),
size: 48
)
VStack(alignment: .leading, spacing: 4) {
Text(load.name)
.font(.body.weight(.medium))
.lineLimit(1)
.truncationMode(.tail)
Text(loadSummary(for: load))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule(style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
metricBadge(
label: "Fuse",
value: "\(recommendedFuse(for: load)) A",
tint: .pink
)
metricBadge(
label: "Cable",
value: wireGaugeString(for: load),
tint: .teal
)
metricBadge(
label: "Length",
value: lengthString(for: load),
tint: .orange
)
Spacer()
}
VStack(alignment: .leading, spacing: 8) {
metricBadge(
label: "Fuse",
value: "\(recommendedFuse(for: load)) A",
tint: .pink
)
metricBadge(
label: "Cable",
value: wireGaugeString(for: load),
tint: .teal
)
metricBadge(
label: "Length",
value: lengthString(for: load),
tint: .orange
)
}
}
}
.padding(.vertical, 16)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.systemBackground))
)
}
private func loadSummary(for load: SavedLoad) -> String {
let voltageText = String(format: "%.1fV", load.voltage)
let lengthText: String
if unitSettings.unitSystem == .metric {
lengthText = String(format: "%.1f%@", load.length, unitSettings.unitSystem.lengthUnit)
} else {
let imperialLength = load.length * 3.28084
lengthText = String(format: "%.1f%@", imperialLength, unitSettings.unitSystem.lengthUnit)
}
let powerOrCurrent = load.isWattMode
? String(format: "%.0fW", load.power)
: String(format: "%.1fA", load.current)
return [voltageText, powerOrCurrent, lengthText].joined(separator: "")
}
private func wireGaugeString(for load: SavedLoad) -> String {
if unitSettings.unitSystem == .imperial {
let awgValue = awgFromCrossSection(load.crossSection)
return String(format: "%.0f AWG", awgValue)
} else {
return String(format: "%.1f mm²", load.crossSection)
}
}
private func lengthString(for load: SavedLoad) -> String {
if unitSettings.unitSystem == .imperial {
let imperialLength = load.length * 3.28084
return String(format: "%.1f ft", imperialLength)
} else {
return String(format: "%.1f m", load.length)
}
}
private func metricBadge(label: String, value: String, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tint)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(tint.opacity(0.12))
)
}
private var emptyStateView: some View {
ComponentsOnboardingView(
onCreate: { createNewLoad() },
onBrowse: { showingComponentLibrary = true }
)
}
private func deleteLoads(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(savedLoads[index])
}
}
}
private func handlePrimaryAction() {
switch selectedComponentTab {
case .components:
createNewLoad()
case .batteries:
startBatteryConfiguration()
case .chargers:
break
}
}
private func createNewLoad() {
let newLoad = SystemComponentsPersistence.createDefaultLoad(
for: system,
in: modelContext,
existingLoads: savedLoads,
existingBatteries: savedBatteries
)
newLoadToEdit = newLoad
}
private func startBatteryConfiguration() {
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
for: system,
existingLoads: savedLoads,
existingBatteries: savedBatteries
)
}
private func saveBattery(_ configuration: BatteryConfiguration) {
SystemComponentsPersistence.saveBattery(
configuration,
for: system,
existingBatteries: savedBatteries,
in: modelContext
)
}
private func editBattery(_ battery: SavedBattery) {
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
}
private func deleteBatteries(_ offsets: IndexSet) {
withAnimation {
SystemComponentsPersistence.deleteBatteries(
at: offsets,
from: savedBatteries,
in: modelContext
)
}
}
private func addComponent(_ item: ComponentLibraryItem) {
let newLoad = SystemComponentsPersistence.createLoad(
from: item,
for: system,
in: modelContext,
existingLoads: savedLoads,
existingBatteries: savedBatteries
)
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),
(1, 42.4), (0, 53.5), (00, 67.4), (000, 85.0), (0000, 107.0)]
// Find the closest AWG size
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
return Double(closest?.0 ?? 20)
}
private func recommendedFuse(for load: SavedLoad) -> Int {
let targetFuse = load.current * 1.25 // 125% of load current for safety
// Common fuse values in amperes
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
// Find the smallest standard fuse that's >= target
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
}
private enum ComponentTab: Hashable {
case components
case batteries
case chargers
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,546 @@
import SwiftUI
import StoreKit
@MainActor
final class CableProPaywallViewModel: ObservableObject {
enum LoadingState: Equatable {
case idle
case loading
case loaded
case failed(String)
}
@Published private(set) var products: [Product] = []
@Published private(set) var state: LoadingState = .idle
@Published private(set) var purchasingProductID: String?
@Published private(set) var isRestoring = false
@Published private(set) var purchasedProductIDs: Set<String> = []
@Published var alert: PaywallAlert?
private let productIdentifiers: [String]
init(productIdentifiers: [String]) {
self.productIdentifiers = productIdentifiers
Task {
await updateCurrentEntitlements()
}
}
func loadProducts(force: Bool = false) async {
if state == .loading { return }
if !force, case .loaded = state { return }
guard !productIdentifiers.isEmpty else {
products = []
state = .loaded
return
}
state = .loading
do {
let fetched = try await Product.products(for: productIdentifiers)
products = fetched.sorted { productSortKey(lhs: $0, rhs: $1) }
state = .loaded
await updateCurrentEntitlements()
} catch {
state = .failed(error.localizedDescription)
}
}
private func productSortKey(lhs: Product, rhs: Product) -> Bool {
sortIndex(for: lhs) < sortIndex(for: rhs)
}
private func sortIndex(for product: Product) -> Int {
guard let period = product.subscription?.subscriptionPeriod else { return Int.max }
switch period.unit {
case .day: return 0
case .week: return 1
case .month: return 2
case .year: return 3
@unknown default: return 10
}
}
func purchase(_ product: Product) async {
guard purchasingProductID == nil else { return }
purchasingProductID = product.id
defer { purchasingProductID = nil }
do {
let result = try await product.purchase()
switch result {
case .success(let verification):
let transaction = try verify(verification)
purchasedProductIDs.insert(transaction.productID)
alert = PaywallAlert(kind: .success, message: localizedString("cable.pro.alert.success.body", defaultValue: "Thanks for supporting Cable PRO!"))
await transaction.finish()
await updateCurrentEntitlements()
case .userCancelled:
break
case .pending:
alert = PaywallAlert(kind: .pending, message: localizedString("cable.pro.alert.pending.body", defaultValue: "Your purchase is awaiting approval."))
@unknown default:
alert = PaywallAlert(kind: .error, message: localizedString("cable.pro.alert.error.generic", defaultValue: "Something went wrong. Please try again."))
}
} catch {
alert = PaywallAlert(kind: .error, message: error.localizedDescription)
}
}
func restorePurchases() async {
guard !isRestoring else { return }
isRestoring = true
defer { isRestoring = false }
do {
try await AppStore.sync()
await updateCurrentEntitlements()
alert = PaywallAlert(kind: .restored, message: localizedString("cable.pro.alert.restored.body", defaultValue: "Your purchases are available again."))
} catch {
alert = PaywallAlert(kind: .error, message: error.localizedDescription)
}
}
private func verify<T>(_ result: VerificationResult<T>) throws -> T {
switch result {
case .verified(let signed):
return signed
case .unverified(_, let error):
throw error
}
}
private func updateCurrentEntitlements() async {
var unlocked: Set<String> = []
for await result in Transaction.currentEntitlements {
switch result {
case .verified(let transaction):
if productIdentifiers.contains(transaction.productID) {
unlocked.insert(transaction.productID)
}
case .unverified:
continue
}
}
purchasedProductIDs = unlocked
}
}
struct CableProPaywallView: View {
@Environment(\.dismiss) private var dismiss
@Binding var isPresented: Bool
@EnvironmentObject private var unitSettings: UnitSystemSettings
@EnvironmentObject private var storeKitManager: StoreKitManager
@StateObject private var viewModel: CableProPaywallViewModel
@State private var alertInfo: PaywallAlert?
private static let defaultProductIds = StoreKitManager.subscriptionProductIDs
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
_isPresented = isPresented
_viewModel = StateObject(wrappedValue: CableProPaywallViewModel(productIdentifiers: productIdentifiers))
}
var body: some View {
NavigationStack {
VStack(spacing: 24) {
header
featureList
plansSection
footer
}
.padding(.horizontal, 20)
.padding(.top, 28)
.padding(.bottom, 16)
.navigationTitle("Cable PRO")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
.task {
await viewModel.loadProducts(force: true)
await storeKitManager.refreshEntitlements()
}
.refreshable {
await viewModel.loadProducts(force: true)
await storeKitManager.refreshEntitlements()
}
}
.onChange(of: viewModel.alert) { newValue in
alertInfo = newValue
}
.alert(item: $alertInfo) { alert in
Alert(
title: Text(alert.title),
message: Text(alert.messageText),
dismissButton: .default(Text("OK")) {
viewModel.alert = nil
alertInfo = nil
}
)
}
.onChange(of: viewModel.purchasedProductIDs) { newValue in
Task { await storeKitManager.refreshEntitlements() }
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 12) {
Text(localizedString("cable.pro.paywall.title", defaultValue: "Unlock Cable PRO"))
.font(.largeTitle.bold())
Text(localizedString("cable.pro.paywall.subtitle", defaultValue: "Cable PRO enables more configuration options for loads, batteries and chargers."))
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var featureList: some View {
VStack(alignment: .leading, spacing: 10) {
paywallFeature(text: localizedString("cable.pro.feature.dutyCycle", defaultValue: "Duty-cycle aware cable calculators"), icon: "bolt.fill")
paywallFeature(text: localizedString("cable.pro.feature.batteryCapacity", defaultValue: "Configure usable battery capacity"), icon: "list.clipboard")
paywallFeature(text: localizedString("cable.pro.feature.usageBased", defaultValue: "Usage based calculations"), icon: "sparkles")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func paywallFeature(text: String, icon: String) -> some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.headline)
.foregroundStyle(Color.accentColor)
.frame(width: 28, height: 28)
.background(
Circle()
.fill(Color.accentColor.opacity(0.12))
)
Text(text)
.font(.callout)
.foregroundStyle(.primary)
}
.padding(.vertical, 4)
}
@ViewBuilder
private var plansSection: some View {
switch viewModel.state {
case .idle, .loading:
RoundedRectangle(cornerRadius: 16)
.fill(Color(.secondarySystemBackground))
.frame(height: 140)
.overlay(ProgressView())
.frame(maxWidth: .infinity)
case .failed(let message):
VStack(spacing: 12) {
Text("We couldn't load Cable PRO at the moment.")
.font(.headline)
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
Button(action: { Task { await viewModel.loadProducts(force: true) } }) {
Text("Try Again")
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
}
.padding()
case .loaded:
if viewModel.products.isEmpty {
VStack(spacing: 12) {
Text("No plans are currently available.")
.font(.headline)
Text("Check back soon—Cable PRO launches in your region shortly.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.secondarySystemBackground))
)
} else {
VStack(spacing: 12) {
let hasActiveSubscription = !viewModel.purchasedProductIDs.isEmpty
ForEach(viewModel.products) { product in
PlanCard(
product: product,
isProcessing: viewModel.purchasingProductID == product.id,
isPurchased: viewModel.purchasedProductIDs.contains(product.id),
isDisabled: hasActiveSubscription && !viewModel.purchasedProductIDs.contains(product.id)
) {
Task {
await viewModel.purchase(product)
}
}
}
}
}
}
}
private var footer: some View {
VStack(spacing: 12) {
Button {
Task {
await viewModel.restorePurchases()
}
} label: {
if viewModel.isRestoring {
ProgressView()
.frame(maxWidth: .infinity)
} else {
Text(localizedString("cable.pro.restore.button", defaultValue: "Restore Purchases"))
.font(.footnote.weight(.semibold))
}
}
.buttonStyle(.borderless)
.padding(.top, 8)
.disabled(viewModel.isRestoring)
HStack(spacing: 16) {
if let termsURL = localizedURL(forKey: "cable.pro.terms.url") {
Link(localizedString("cable.pro.terms.label", defaultValue: "Terms"), destination: termsURL)
}
if let privacyURL = localizedURL(forKey: "cable.pro.privacy.url") {
Link(localizedString("cable.pro.privacy.label", defaultValue: "Privacy"), destination: privacyURL)
}
}
.font(.footnote)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
private func localizedURL(forKey key: String) -> URL? {
let raw = localizedString(key, defaultValue: "")
guard let url = URL(string: raw), !raw.isEmpty else { return nil }
return url
}
}
private func localizedString(_ key: String, defaultValue: String) -> String {
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
}
private func localizedDurationString(for period: Product.SubscriptionPeriod) -> String {
let locale = Locale.autoupdatingCurrent
let number = localizedNumber(period.value, locale: locale)
let unitBase: String
switch period.unit {
case .day: unitBase = "day"
case .week: unitBase = "week"
case .month: unitBase = "month"
case .year: unitBase = "year"
@unknown default: unitBase = "day"
}
if period.value == 1 {
let key = "cable.pro.duration.\(unitBase).singular"
return localizedString(key, defaultValue: singularDurationFallback(for: unitBase))
} else {
let key = "cable.pro.duration.\(unitBase).plural"
let template = localizedString(key, defaultValue: pluralDurationFallback(for: unitBase))
return String(format: template, number)
}
}
private func localizedNumber(_ value: Int, locale: Locale) -> String {
let formatter = NumberFormatter()
formatter.locale = locale
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: value)) ?? String(value)
}
private func singularDurationFallback(for unit: String) -> String {
switch unit {
case "day": return "every day"
case "week": return "every week"
case "month": return "every month"
case "year": return "every year"
default: return "every day"
}
}
private func pluralDurationFallback(for unit: String) -> String {
switch unit {
case "day": return "every %@ days"
case "week": return "every %@ weeks"
case "month": return "every %@ months"
case "year": return "every %@ years"
default: return "every %@ days"
}
}
private func trialDurationString(for period: Product.SubscriptionPeriod) -> String {
let locale = Locale.autoupdatingCurrent
let number = localizedNumber(period.value, locale: locale)
let unitBase: String
switch period.unit {
case .day: unitBase = "day"
case .week: unitBase = "week"
case .month: unitBase = "month"
case .year: unitBase = "year"
@unknown default: unitBase = "day"
}
let key = "cable.pro.trial.duration.\(unitBase).\(period.value == 1 ? "singular" : "plural")"
let fallbackTemplate: String
switch unitBase {
case "day": fallbackTemplate = "%@-day"
case "week": fallbackTemplate = "%@-week"
case "month": fallbackTemplate = "%@-month"
case "year": fallbackTemplate = "%@-year"
default: fallbackTemplate = "%@-day"
}
let template = localizedString(key, defaultValue: fallbackTemplate)
if template.contains("%@") {
return String(format: template, number)
} else {
return template
}
}
private struct PlanCard: View {
let product: Product
let isProcessing: Bool
let isPurchased: Bool
let isDisabled: Bool
let action: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .firstTextBaseline) {
Text(product.displayName)
.font(.headline)
Spacer()
Text(product.displayPrice)
.font(.headline)
}
if let info = product.subscription {
VStack(alignment: .leading, spacing: 6) {
if let trial = trialDescription(for: info) {
Text(trial)
.font(.caption.weight(.semibold))
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(
Capsule()
.fill(Color.accentColor.opacity(0.15))
)
.foregroundStyle(Color.accentColor)
}
Text(subscriptionDescription(for: info))
.font(.caption)
.foregroundStyle(.secondary)
}
}
Button(action: action) {
Group {
if isProcessing {
ProgressView()
} else if isPurchased {
Label(localizedString("cable.pro.button.unlocked", defaultValue: "Unlocked"), systemImage: "checkmark.circle.fill")
.labelStyle(.titleAndIcon)
} else {
let titleKey = product.subscription?.introductoryOffer != nil ? "cable.pro.button.freeTrial" : "cable.pro.button.unlock"
Text(localizedString(titleKey, defaultValue: product.subscription?.introductoryOffer != nil ? "Start Free Trial" : "Unlock Now"))
}
}
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
.buttonStyle(.borderedProminent)
.disabled(isProcessing || isPurchased || isDisabled)
.opacity((isPurchased || isDisabled) ? 0.6 : 1)
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(isPurchased ? Color.accentColor : Color.clear, lineWidth: isPurchased ? 2 : 0)
)
}
private func trialDescription(for info: Product.SubscriptionInfo) -> String? {
guard
let offer = info.introductoryOffer,
offer.paymentMode == .freeTrial
else { return nil }
let duration = trialDurationString(for: offer.period)
let template = localizedString("cable.pro.trial.badge", defaultValue: "Includes a %@ free trial")
return String(format: template, duration)
}
private func subscriptionDescription(for info: Product.SubscriptionInfo) -> String {
let quantity = localizedDurationString(for: info.subscriptionPeriod)
let templateKey: String
if let offer = info.introductoryOffer,
offer.paymentMode == .freeTrial {
templateKey = "cable.pro.subscription.trialThenRenews"
} else {
templateKey = "cable.pro.subscription.renews"
}
let template = localizedString(templateKey, defaultValue: templateKey == "cable.pro.subscription.trialThenRenews" ? "Free trial, then renews every %@." : "Renews every %@.")
return String(format: template, quantity)
}
}
struct PaywallAlert: Identifiable, Equatable {
enum Kind { case success, pending, restored, error }
let id = UUID()
let kind: Kind
let message: String
var title: String {
switch kind {
case .success:
return localizedString("cable.pro.alert.success.title", defaultValue: "Cable PRO Unlocked")
case .pending:
return localizedString("cable.pro.alert.pending.title", defaultValue: "Purchase Pending")
case .restored:
return localizedString("cable.pro.alert.restored.title", defaultValue: "Purchases Restored")
case .error:
return localizedString("cable.pro.alert.error.title", defaultValue: "Purchase Failed")
}
}
var messageText: String {
message
}
}
#Preview {
let unitSettings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: unitSettings)
return CableProPaywallView(isPresented: .constant(true))
.environmentObject(unitSettings)
.environmentObject(manager)
}

View File

@@ -7,8 +7,16 @@ class SavedBattery {
var name: String
var nominalVoltage: Double
var capacityAmpHours: Double
var usableCapacityOverrideFraction: Double?
var chargeVoltage: Double?
var cutOffVoltage: Double?
var minimumTemperatureCelsius: Double?
var maximumTemperatureCelsius: Double?
private var chemistryRawValue: String
var iconName: String = "battery.100"
var colorName: String = "blue"
var system: ElectricalSystem?
var bomCompletedItemIDs: [String] = []
var timestamp: Date
init(
@@ -17,15 +25,31 @@ class SavedBattery {
nominalVoltage: Double = 12.8,
capacityAmpHours: Double = 100,
chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate,
usableCapacityOverrideFraction: Double? = nil,
chargeVoltage: Double? = nil,
cutOffVoltage: Double? = nil,
minimumTemperatureCelsius: Double? = nil,
maximumTemperatureCelsius: Double? = nil,
iconName: String = "battery.100",
colorName: String = "blue",
system: ElectricalSystem? = nil,
bomCompletedItemIDs: [String] = [],
timestamp: Date = Date()
) {
self.id = id
self.name = name
self.nominalVoltage = nominalVoltage
self.capacityAmpHours = capacityAmpHours
self.usableCapacityOverrideFraction = usableCapacityOverrideFraction
self.chargeVoltage = chargeVoltage
self.cutOffVoltage = cutOffVoltage
self.minimumTemperatureCelsius = minimumTemperatureCelsius
self.maximumTemperatureCelsius = maximumTemperatureCelsius
self.chemistryRawValue = chemistry.rawValue
self.iconName = iconName
self.colorName = colorName
self.system = system
self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp
}
@@ -41,4 +65,18 @@ class SavedBattery {
var energyWattHours: Double {
nominalVoltage * capacityAmpHours
}
var usableCapacityAmpHours: Double {
let fraction: Double
if let override = usableCapacityOverrideFraction {
fraction = max(0, min(1, override))
} else {
fraction = chemistry.usableCapacityFraction
}
return capacityAmpHours * fraction
}
var usableEnergyWattHours: Double {
usableCapacityAmpHours * nominalVoltage
}
}

View File

@@ -7,11 +7,14 @@
import SwiftUI
import SwiftData
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@EnvironmentObject private var storeKitManager: StoreKitManager
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var showingProPaywall = false
var body: some View {
NavigationStack {
@@ -24,27 +27,9 @@ struct SettingsView: View {
}
.pickerStyle(.segmented)
}
Section {
HStack {
Text("Wire Cross-Section:")
Spacer()
Text(unitSettings.unitSystem.wireAreaUnit)
.foregroundColor(.secondary)
Section("Cable PRO") {
proSectionContent
}
HStack {
Text("Length:")
Spacer()
Text(unitSettings.unitSystem.lengthUnit)
.foregroundColor(.secondary)
}
} header: {
Text("Current Units")
} footer: {
Text("Changing the unit system will apply to all calculations in the app.")
}
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
@@ -87,5 +72,141 @@ struct SettingsView: View {
}
}
}
.sheet(isPresented: $showingProPaywall) {
CableProPaywallView(isPresented: $showingProPaywall)
}
.onChange(of: showingProPaywall) { isPresented in
if !isPresented {
Task { await storeKitManager.refreshEntitlements() }
}
}
.onAppear {
Task { await storeKitManager.refreshEntitlements() }
}
}
@ViewBuilder
private var proSectionContent: some View {
if storeKitManager.isRefreshing && storeKitManager.status == nil {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if let status = storeKitManager.status {
VStack(alignment: .leading, spacing: 8) {
Label(status.displayName, systemImage: "checkmark.seal.fill")
.font(.headline)
if let renewalDate = status.renewalDate {
Text(renewalText(for: renewalDate))
.font(.footnote)
.foregroundStyle(.secondary)
}
if let trialText = trialMessage(for: status) {
Text(trialText)
.font(.footnote)
.foregroundStyle(.secondary)
}
if status.isInGracePeriod {
Text(localizedString("settings.pro.grace_period", defaultValue: "We're retrying your last payment; access remains during the grace period."))
.font(.footnote)
.foregroundStyle(.secondary)
}
if let isAutoRenewEnabled = status.isAutoRenewEnabled, !isAutoRenewEnabled {
Text(localizedString("settings.pro.autorenew.off", defaultValue: "Auto-renew is off—consider renewing to keep Cable PRO."))
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 4)
Button {
openManageSubscriptions()
} label: {
Text(localizedString("settings.pro.manage.button", defaultValue: "Manage Subscription"))
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(6)
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 4)
} else {
VStack(alignment: .leading, spacing: 10) {
Text(localizedString("settings.pro.cta.description", defaultValue: "Cable PRO keeps advanced calculations and early tools available."))
.font(.body)
.foregroundStyle(.secondary)
Button {
showingProPaywall = true
} label: {
Text(localizedString("settings.pro.cta.button", defaultValue: "Get Cable PRO"))
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(6)
}
.buttonStyle(.borderedProminent)
}
}
}
private func renewalText(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
formatter.locale = Locale.autoupdatingCurrent
let dateString = formatter.string(from: date)
let template = localizedString("settings.pro.renewal.date", defaultValue: "Renews on %@.")
return String(format: template, dateString)
}
private func trialMessage(for status: StoreKitManager.SubscriptionStatus) -> String? {
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
if days > 0 {
let dayText = localizedDayCount(days)
let template = localizedString("settings.pro.trial.remaining", defaultValue: "%@ remaining in free trial.")
return String(format: template, dayText)
} else {
return localizedString("settings.pro.trial.today", defaultValue: "Free trial renews today.")
}
}
private func localizedDayCount(_ days: Int) -> String {
let number = localizedNumber(days)
let key = days == 1 ? "settings.pro.day.one" : "settings.pro.day.other"
let template = localizedString(key, defaultValue: days == 1 ? "%@ day" : "%@ days")
return String(format: template, number)
}
private func openManageSubscriptions() {
guard let url = URL(string: localizedString("settings.pro.manage.url", defaultValue: "https://apps.apple.com/account/subscriptions")) else { return }
openURL(url)
}
private func localizedNumber(_ value: Int) -> String {
let formatter = NumberFormatter()
formatter.locale = Locale.autoupdatingCurrent
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: value)) ?? String(value)
}
private func localizedString(_ key: String, defaultValue: String) -> String {
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
}
}
#Preview("Settings (Default)") {
let settings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: settings)
return SettingsView()
.environmentObject(settings)
.environmentObject(manager)
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
/// Reusable wrapper that applies the system overview stats card styling to a header view.
struct StatsHeaderContainer<Content: View>: View {
private let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
Group {
if #available(iOS 26.0, *) {
card
.glassEffect(.regular, in: .rect(cornerRadius: 20))
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 12)
} else {
card
.padding(.horizontal, 16)
.padding(.top, 20)
.padding(.bottom, 16)
.background(Color(.systemGroupedBackground))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.strokeBorder(.white.opacity(0.15))
)
}
}
}
private var card: some View {
content
.padding(.vertical, 18)
.padding(.horizontal, 20)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(red: 81 / 255, green: 144 / 255, blue: 152 / 255).opacity(0.12))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
)
)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}

221
Cable/StoreKitManager.swift Normal file
View File

@@ -0,0 +1,221 @@
import Foundation
import StoreKit
@MainActor
final class StoreKitManager: ObservableObject {
struct SubscriptionStatus: Equatable {
let productId: String
let displayName: String
let renewalDate: Date?
let isInTrial: Bool
let trialEndDate: Date?
let isInGracePeriod: Bool
let isAutoRenewEnabled: Bool?
}
nonisolated static let subscriptionProductIDs: [String] = [
"app.voltplan.cable.weekly",
"app.voltplan.cable.yearly"
]
@Published private(set) var status: SubscriptionStatus?
@Published private(set) var isRefreshing = false
var isProUnlocked: Bool {
status != nil
}
private let productIDs: Set<String>
private weak var unitSettings: UnitSystemSettings?
private var updatesTask: Task<Void, Never>?
private var productCache: [String: Product] = [:]
init(
productIDs: [String] = StoreKitManager.subscriptionProductIDs,
unitSettings: UnitSystemSettings? = nil
) {
self.productIDs = Set(productIDs)
self.unitSettings = unitSettings
updatesTask = Task { [weak self] in
await self?.observeTransactionUpdates()
}
Task { [weak self] in
await self?.finishUnfinishedTransactions()
await self?.refreshEntitlements()
}
}
deinit {
updatesTask?.cancel()
}
func attachUnitSettings(_ settings: UnitSystemSettings) {
unitSettings = settings
Task { [weak self] in
await self?.refreshEntitlements()
}
}
func refreshEntitlements() async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
let resolvedStatus = await loadCurrentStatus()
status = resolvedStatus
unitSettings?.isProUnlocked = resolvedStatus != nil
}
private func loadCurrentStatus() async -> SubscriptionStatus? {
if let entitlementStatus = await statusFromCurrentEntitlements() {
return entitlementStatus
}
return await statusFromLatestTransactions()
}
private func statusFromCurrentEntitlements() async -> SubscriptionStatus? {
var newestTransaction: StoreKit.Transaction?
for await result in StoreKit.Transaction.currentEntitlements {
guard case .verified(let transaction) = result,
productIDs.contains(transaction.productID),
transaction.revocationDate == nil,
!isExpired(transaction) else { continue }
if let existing = newestTransaction {
let existingExpiration = existing.expirationDate ?? .distantPast
let candidateExpiration = transaction.expirationDate ?? .distantPast
if candidateExpiration > existingExpiration {
newestTransaction = transaction
}
} else {
newestTransaction = transaction
}
}
guard let activeTransaction = newestTransaction else { return nil }
return await status(for: activeTransaction)
}
private func statusFromLatestTransactions() async -> SubscriptionStatus? {
var newestTransaction: StoreKit.Transaction?
for productID in productIDs {
guard let latestResult = await StoreKit.Transaction.latest(for: productID) else { continue }
guard case .verified(let transaction) = latestResult,
transaction.revocationDate == nil,
!isExpired(transaction) else { continue }
if let existing = newestTransaction {
let existingExpiration = existing.expirationDate ?? .distantPast
let candidateExpiration = transaction.expirationDate ?? .distantPast
if candidateExpiration > existingExpiration {
newestTransaction = transaction
}
} else {
newestTransaction = transaction
}
}
guard let activeTransaction = newestTransaction else { return nil }
return await status(for: activeTransaction)
}
private func observeTransactionUpdates() async {
for await result in StoreKit.Transaction.updates {
guard !Task.isCancelled else { return }
switch result {
case .verified(let transaction):
await transaction.finish()
await refreshEntitlements()
case .unverified:
continue
}
}
}
private func finishUnfinishedTransactions() async {
for await result in StoreKit.Transaction.unfinished {
guard case .verified(let transaction) = result else { continue }
await transaction.finish()
}
}
private func status(for transaction: StoreKit.Transaction) async -> SubscriptionStatus? {
let product = await product(for: transaction.productID)
let displayName = product?.displayName ?? transaction.productID
var isInGracePeriod = false
var isAutoRenewEnabled: Bool?
var isInTrial = false
var trialEndDate: Date?
if let currentStatus = await transaction.subscriptionStatus {
if currentStatus.state == .inGracePeriod {
isInGracePeriod = true
}
if case .verified(let renewalInfo) = currentStatus.renewalInfo {
isAutoRenewEnabled = renewalInfo.willAutoRenew
if renewalInfo.gracePeriodExpirationDate != nil {
isInGracePeriod = true
}
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
if let offer = renewalInfo.offer, offer.type == .introductory {
isInTrial = true
trialEndDate = transaction.expirationDate
}
} else {
#if compiler(>=5.3)
if renewalInfo.offerType == .introductory {
isInTrial = true
trialEndDate = transaction.expirationDate
}
#endif
}
} else if case .verified(let statusTransaction) = currentStatus.transaction {
if let offer = statusTransaction.offer, offer.type == .introductory {
isInTrial = true
trialEndDate = statusTransaction.expirationDate ?? transaction.expirationDate
}
}
} else if let offer = transaction.offer, offer.type == .introductory {
isInTrial = true
trialEndDate = transaction.expirationDate
}
return SubscriptionStatus(
productId: transaction.productID,
displayName: displayName,
renewalDate: transaction.expirationDate,
isInTrial: isInTrial,
trialEndDate: trialEndDate,
isInGracePeriod: isInGracePeriod,
isAutoRenewEnabled: isAutoRenewEnabled
)
}
private func isExpired(_ transaction: StoreKit.Transaction) -> Bool {
if let expirationDate = transaction.expirationDate {
return expirationDate < Date()
}
return false
}
private func product(for id: String) async -> Product? {
if let cached = productCache[id] {
return cached
}
guard let product = try? await Product.products(for: [id]).first else { return nil }
productCache[id] = product
return product
}
}

View File

@@ -1,382 +0,0 @@
//
// SystemBillOfMaterialsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemBillOfMaterialsView: View {
let systemName: String
let loads: [SavedLoad]
let unitSystem: UnitSystem
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var completedItemIDs: Set<String>
@State private var suppressRowTapForID: String?
private struct Item: Identifiable {
enum Destination {
case affiliate(URL)
case amazonSearch(String)
}
let id: String
let logicalID: String
let title: String
let detail: String
let iconSystemName: String
let destination: Destination
let isPrimaryComponent: Bool
}
init(systemName: String, loads: [SavedLoad], unitSystem: UnitSystem) {
self.systemName = systemName
self.loads = loads
self.unitSystem = unitSystem
let initialKeys = loads.flatMap { load in
load.bomCompletedItemIDs.map { SystemBillOfMaterialsView.storageKey(for: load, itemID: $0) }
}
_completedItemIDs = State(initialValue: Set(initialKeys))
_suppressRowTapForID = State(initialValue: nil)
}
var body: some View {
NavigationStack {
List {
if sortedLoads.isEmpty {
Section("Components") {
Text("No loads saved in this system yet.")
.font(.footnote)
.foregroundColor(.secondary)
}
} else {
ForEach(sortedLoads) { load in
Section(header: sectionHeader(for: load)) {
ForEach(items(for: load)) { item in
let isCompleted = completedItemIDs.contains(item.id)
let destinationURL = destinationURL(for: item.destination, load: load)
HStack(spacing: 12) {
let accessibilityLabel: String = {
if isCompleted {
let format = NSLocalizedString(
"bom.accessibility.mark.incomplete",
comment: "Accessibility label instructing VoiceOver to mark an item incomplete"
)
return String.localizedStringWithFormat(format, item.title)
} else {
let format = NSLocalizedString(
"bom.accessibility.mark.complete",
comment: "Accessibility label instructing VoiceOver to mark an item complete"
)
return String.localizedStringWithFormat(format, item.title)
}
}()
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(isCompleted ? .accentColor : .secondary)
.imageScale(.large)
.onTapGesture {
setCompletion(!isCompleted, for: load, item: item)
suppressRowTapForID = item.id
}
.accessibilityLabel(accessibilityLabel)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
.foregroundStyle(isCompleted ? Color.primary.opacity(0.7) : Color.primary)
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
if item.isPrimaryComponent {
Text(String(localized: "component.fallback.name", comment: "Tag label marking an item as the component itself"))
.font(.caption2.weight(.medium))
.foregroundColor(.accentColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.15), in: Capsule())
}
Text(item.detail)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer(minLength: 8)
if destinationURL != nil {
Image(systemName: "arrow.up.right")
.font(.footnote.weight(.semibold))
.foregroundColor(.secondary)
}
}
.padding(.vertical, 10)
.contentShape(Rectangle())
.listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 16))
.listRowBackground(
Color(.secondarySystemGroupedBackground)
)
.onTapGesture {
if suppressRowTapForID == item.id {
suppressRowTapForID = nil
return
}
if let destinationURL {
openURL(destinationURL)
}
setCompletion(true, for: load, item: item)
suppressRowTapForID = nil
suppressRowTapForID = nil
}
}
}
}
}
Section {
Text(footerMessage)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
.navigationTitle(
String(
format: NSLocalizedString(
"bom.navigation.title.system",
comment: "Navigation title for the bill of materials view"
),
locale: Locale.current,
systemName
)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
.onAppear {
refreshCompletedItems()
suppressRowTapForID = nil
}
}
.accessibilityIdentifier("system-bom-view")
}
private var sortedLoads: [SavedLoad] {
loads.sorted { lhs, rhs in
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private func sectionHeader(for load: SavedLoad) -> some View {
VStack(alignment: .leading, spacing: 2) {
let fallbackTitle = String(localized: "component.fallback.name", comment: "Fallback title for a component that lacks a name")
Text(load.name.isEmpty ? fallbackTitle : load.name)
.font(.headline)
Text(dateFormatter.string(from: load.timestamp))
.font(.caption)
.foregroundColor(.secondary)
}
}
private func items(for load: SavedLoad) -> [Item] {
let lengthValue: Double
if unitSystem == .imperial {
lengthValue = load.length * 3.28084
} else {
lengthValue = load.length
}
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
let crossSectionLabel: String
let gaugeQuery: String
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is not yet determined")
if unitSystem == .imperial {
let awg = awgFromCrossSection(load.crossSection)
if awg > 0 {
crossSectionLabel = String(format: "AWG %.0f", awg)
gaugeQuery = String(format: "AWG %.0f", awg)
} else {
crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable"
}
} else {
if load.crossSection > 0 {
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
} else {
crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable"
}
}
let cableDetail = "\(lengthLabel)\(crossSectionLabel)"
let calculatedPower = load.power > 0 ? load.power : load.voltage * load.current
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
let fuseRating = recommendedFuse(for: load)
let fuseDetailFormat = NSLocalizedString(
"bom.fuse.detail",
comment: "Description for the fuse item in the BOM list"
)
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
let cableShoesDetailFormat = NSLocalizedString(
"bom.terminals.detail",
comment: "Description for the cable terminals item in the BOM list"
)
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceQuery = load.name.isEmpty
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage)
: load.name
let redCableQuery = "\(gaugeQuery) red battery cable"
let blackCableQuery = "\(gaugeQuery) black battery cable"
let fuseQuery = "inline fuse holder \(fuseRating)A"
let terminalQuery = "\(gaugeQuery) cable shoes"
let items: [Item] = [
Item(
id: Self.storageKey(for: load, itemID: "component"),
logicalID: "component",
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
detail: powerDetail,
iconSystemName: "bolt.fill",
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
isPrimaryComponent: true
),
Item(
id: Self.storageKey(for: load, itemID: "cable-red"),
logicalID: "cable-red",
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery),
isPrimaryComponent: false
),
Item(
id: Self.storageKey(for: load, itemID: "cable-black"),
logicalID: "cable-black",
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery),
isPrimaryComponent: false
),
Item(
id: Self.storageKey(for: load, itemID: "fuse"),
logicalID: "fuse",
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
detail: fuseDetail,
iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery),
isPrimaryComponent: false
),
Item(
id: Self.storageKey(for: load, itemID: "terminals"),
logicalID: "terminals",
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery),
isPrimaryComponent: false
)
]
return items
}
private func destinationURL(for destination: Item.Destination, load: SavedLoad) -> URL? {
switch destination {
case .affiliate(let url):
return url
case .amazonSearch(let query):
let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
}
}
private static func storageKey(for load: SavedLoad, itemID: String) -> String {
if load.identifier.isEmpty {
load.identifier = UUID().uuidString
}
return "\(load.identifier)::\(itemID)"
}
private func setCompletion(_ isCompleted: Bool, for load: SavedLoad, item: Item) {
if isCompleted {
completedItemIDs.insert(item.id)
} else {
completedItemIDs.remove(item.id)
}
if load.identifier.isEmpty {
load.identifier = UUID().uuidString
}
var stored = Set(load.bomCompletedItemIDs)
if isCompleted {
stored.insert(item.logicalID)
} else {
stored.remove(item.logicalID)
}
load.bomCompletedItemIDs = Array(stored).sorted()
}
private func refreshCompletedItems() {
let keys = loads.flatMap { load in
load.bomCompletedItemIDs.map { Self.storageKey(for: load, itemID: $0) }
}
completedItemIDs = Set(keys)
}
private func recommendedFuse(for load: SavedLoad) -> Int {
let targetFuse = load.current * 1.25
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last ?? 0
}
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let mapping: [(awg: Double, area: Double)] = [
(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), (1, 42.4), (0, 53.5),
(00, 67.4), (000, 85.0), (0000, 107.0)
]
guard crossSectionMM2 > 0 else { return 0 }
let closest = mapping.min { lhs, rhs in
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
}
return closest?.awg ?? 0
}
private var footerMessage: String {
NSLocalizedString(
"affiliate.disclaimer",
comment: "Footer note reminding users that affiliate purchases may support the app"
)
}
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}
}

View File

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

View File

@@ -0,0 +1,17 @@
import Foundation
struct BillOfMaterialsItemSnapshot: Identifiable {
let id: String
let title: String
let detail: String
let iconSystemName: String
let isPrimaryComponent: Bool
let metric: String?
}
struct BillOfMaterialsSectionSnapshot: Identifiable {
let id: String
let title: String
let subtitle: String
let items: [BillOfMaterialsItemSnapshot]
}

View File

@@ -0,0 +1,313 @@
import Foundation
import UIKit
struct SystemBillOfMaterialsPDFExporter {
private let pageRect = CGRect(x: 0, y: 0, width: 595, height: 842) // A4 portrait in points
private let margin: CGFloat = 40
private let primaryTextColor = UIColor.black
private let secondaryTextColor = UIColor.darkGray
private let tertiaryTextColor = UIColor.gray
private let accentColor = UIColor(red: 0.45, green: 0.34, blue: 0.86, alpha: 1)
func export(
systemName: String,
unitSystem: UnitSystem,
sections: [BillOfMaterialsSectionSnapshot]
) throws -> URL {
let format = UIGraphicsPDFRendererFormat()
let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
var pageIndex = 1
let data = renderer.pdfData { context in
var cursorY = beginPage(
context: context,
pageIndex: pageIndex,
systemName: systemName,
unitSystem: unitSystem,
isFirstPage: true
)
if sections.isEmpty {
cursorY = ensureSpace(
requiredHeight: 60,
cursorY: cursorY,
context: context,
pageIndex: &pageIndex,
systemName: systemName,
unitSystem: unitSystem
)
let emptyMessage = NSLocalizedString(
"bom.pdf.placeholder.empty",
comment: "Message shown in the PDF export when no components are available"
)
drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY)
} else {
for section in sections {
let requiredHeight = sectionHeight(for: section)
cursorY = ensureSpace(
requiredHeight: requiredHeight,
cursorY: cursorY,
context: context,
pageIndex: &pageIndex,
systemName: systemName,
unitSystem: unitSystem
)
cursorY = drawSectionHeader(
title: section.title,
subtitle: section.subtitle,
at: cursorY,
in: context.cgContext
)
for item in section.items {
cursorY = drawItem(item, at: cursorY, in: context.cgContext)
cursorY += 12
}
cursorY += 8
}
}
drawFooter(pageIndex: pageIndex, in: context.cgContext)
}
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("System-BOM-\(UUID().uuidString).pdf")
try data.write(to: url, options: .atomic)
return url
}
private func beginPage(
context: UIGraphicsPDFRendererContext,
pageIndex: Int,
systemName: String,
unitSystem: UnitSystem,
isFirstPage: Bool
) -> CGFloat {
context.beginPage()
let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold)
let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium)
let title = isFirstPage
? NSLocalizedString(
"bom.pdf.header.title",
comment: "Primary title shown at the top of the BOM PDF"
)
: systemName
let subtitle: String
if isFirstPage {
let format = NSLocalizedString(
"bom.pdf.header.subtitle",
comment: "Subtitle format combining system name and unit system for the BOM PDF"
)
subtitle = String(
format: format,
locale: Locale.current,
systemName,
unitSystem.displayName
)
} else {
let format = NSLocalizedString(
"bom.pdf.header.inline",
comment: "Subtitle describing the active unit system on subsequent PDF pages"
)
subtitle = String(
format: format,
locale: Locale.current,
unitSystem.displayName
)
}
let availableWidth = pageRect.width - (margin * 2)
let titleRect = CGRect(x: margin, y: margin, width: availableWidth, height: titleFont.lineHeight + 4)
title.draw(in: titleRect, withAttributes: [
.font: titleFont,
.foregroundColor: primaryTextColor
])
let subtitleRect = CGRect(
x: margin,
y: titleRect.maxY + 4,
width: availableWidth,
height: subtitleFont.lineHeight + 2
)
subtitle.draw(in: subtitleRect, withAttributes: [
.font: subtitleFont,
.foregroundColor: secondaryTextColor
])
return subtitleRect.maxY + (isFirstPage ? 24 : 12)
}
private func ensureSpace(
requiredHeight: CGFloat,
cursorY: CGFloat,
context: UIGraphicsPDFRendererContext,
pageIndex: inout Int,
systemName: String,
unitSystem: UnitSystem
) -> CGFloat {
if cursorY + requiredHeight <= pageRect.height - margin {
return cursorY
}
drawFooter(pageIndex: pageIndex, in: context.cgContext)
pageIndex += 1
return beginPage(
context: context,
pageIndex: pageIndex,
systemName: systemName,
unitSystem: unitSystem,
isFirstPage: false
)
}
private var sectionHeaderHeight: CGFloat {
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
return headerFont.lineHeight + subtitleFont.lineHeight + 14
}
private func sectionHeight(for section: BillOfMaterialsSectionSnapshot) -> CGFloat {
let itemsHeight = section.items.reduce(0) { partialResult, item in
partialResult + itemBlockHeight(for: item) + 12
}
return sectionHeaderHeight + itemsHeight + 8
}
private func drawSectionHeader(title: String, subtitle: String, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
var cursorY = yPosition
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
let availableWidth = pageRect.width - (margin * 2)
title.draw(
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: headerFont.lineHeight + 4),
withAttributes: [
.font: headerFont,
.foregroundColor: primaryTextColor
]
)
cursorY += headerFont.lineHeight + 4
subtitle.draw(
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: subtitleFont.lineHeight + 2),
withAttributes: [
.font: subtitleFont,
.foregroundColor: secondaryTextColor
]
)
cursorY += subtitleFont.lineHeight + 10
return cursorY
}
private func itemBlockHeight(for item: BillOfMaterialsItemSnapshot) -> CGFloat {
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
var height: CGFloat = 0
if item.metric != nil {
height += metricFont.lineHeight + 2
}
height += titleFont.lineHeight + 2
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
height += detailFont.lineHeight + 4
}
return height + 4
}
private func drawItem(_ item: BillOfMaterialsItemSnapshot, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: titleFont,
.foregroundColor: primaryTextColor
]
let detailAttributes: [NSAttributedString.Key: Any] = [
.font: detailFont,
.foregroundColor: secondaryTextColor
]
let metricAttributes: [NSAttributedString.Key: Any] = [
.font: metricFont,
.foregroundColor: accentColor
]
let bulletWidth: CGFloat = 6
let spacing: CGFloat = 8
let availableWidth = pageRect.width - (margin * 2) - bulletWidth - spacing
let firstLineHeight = item.metric != nil ? metricFont.lineHeight : titleFont.lineHeight
let bulletRect = CGRect(
x: margin,
y: yPosition + (firstLineHeight / 2) - (bulletWidth / 2),
width: bulletWidth,
height: bulletWidth
)
context.setFillColor(accentColor.cgColor)
context.fillEllipse(in: bulletRect)
var cursorY = yPosition
let textX = margin + bulletWidth + spacing
if let metric = item.metric {
let metricRect = CGRect(x: textX, y: cursorY, width: availableWidth, height: metricFont.lineHeight + 2)
metric.draw(in: metricRect, withAttributes: metricAttributes)
cursorY = metricRect.maxY + 2
}
let titleRect = CGRect(
x: textX,
y: cursorY,
width: availableWidth,
height: titleFont.lineHeight + 2
)
item.title.draw(in: titleRect, withAttributes: titleAttributes)
cursorY = titleRect.maxY + 2
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
let detailRect = CGRect(
x: textX,
y: cursorY,
width: availableWidth,
height: detailFont.lineHeight + 4
)
item.detail.draw(in: detailRect, withAttributes: detailAttributes)
cursorY = detailRect.maxY
}
return cursorY
}
private func drawFooter(pageIndex: Int, in context: CGContext) {
let footerFont = UIFont.systemFont(ofSize: 11, weight: .regular)
let attributes: [NSAttributedString.Key: Any] = [
.font: footerFont,
.foregroundColor: tertiaryTextColor
]
let format = NSLocalizedString(
"bom.pdf.page.number",
comment: "Format string for the PDF page number footer"
)
let text = String(format: format, locale: Locale.current, pageIndex)
let size = text.size(withAttributes: attributes)
let origin = CGPoint(
x: (pageRect.width - size.width) / 2,
y: pageRect.height - margin + 10
)
text.draw(at: origin, withAttributes: attributes)
}
private func drawPlaceholder(in context: CGContext, text: String, at yPosition: CGFloat) {
let font = UIFont.systemFont(ofSize: 14, weight: .regular)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: secondaryTextColor
]
text.draw(
in: CGRect(x: margin, y: yPosition, width: pageRect.width - (margin * 2), height: font.lineHeight + 4),
withAttributes: attributes
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,43 @@ struct SystemComponentsPersistence {
nominalVoltage: configuration.nominalVoltage,
capacityAmpHours: configuration.capacityAmpHours,
chemistry: configuration.chemistry,
usableCapacityOverrideFraction: configuration.usableCapacityOverrideFraction,
chargeVoltage: configuration.chargeVoltage,
cutOffVoltage: configuration.cutOffVoltage,
minimumTemperatureCelsius: configuration.minimumTemperatureCelsius,
maximumTemperatureCelsius: configuration.maximumTemperatureCelsius,
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 +200,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 +236,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
}
}

View File

@@ -92,6 +92,9 @@ struct SystemsOnboardingView: View {
.background(Color(.systemGroupedBackground))
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
.task {
AnalyticsTracker.log("Launched")
}
}
private func resetState() {
@@ -102,6 +105,7 @@ struct SystemsOnboardingView: View {
private func createSystem() {
isFieldFocused = false
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
AnalyticsTracker.log("System Created", properties: ["name": trimmed])
guard !trimmed.isEmpty else { return }
onCreate(trimmed)
}

View File

@@ -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",
@@ -75,11 +76,98 @@ struct SystemsView: View {
} else {
List {
ForEach(systems) { system in
NavigationLink(destination: LoadsView(system: system)) {
Button {
handleSystemSelection(system)
} label: {
systemRow(for: system)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(system.name)
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
.accessibilityAddTraits(.isButton)
}
.onDelete(perform: deleteSystems)
}
.accessibilityIdentifier("systems-list")
}
}
.navigationTitle("Systems")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
openSettings()
} label: {
Image(systemName: "gearshape")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
AnalyticsTracker.log("System Create Navigation")
createNewSystem()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $systemNavigationTarget) { target in
LoadsView(
system: target.system,
presentSystemEditorOnAppear: target.presentSystemEditor,
loadToOpenOnAppear: target.loadToOpenOnAppear
)
}
}
.onAppear {
performInitialAutoNavigationIfNeeded()
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponentFromLibrary(item)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(unitSettings)
}
}
private var systemsEmptyState: some View {
SystemsOnboardingView { name in
createOnboardingSystem(named: name)
}
}
private func openSettings() {
AnalyticsTracker.log("Settings Opened")
showingSettings = true
}
private func handleSystemSelection(_ system: ElectricalSystem) {
AnalyticsTracker.log(
"System Opened",
properties: [
"name": system.name,
"source": "list"
]
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "list"
)
}
@ViewBuilder
private func systemRow(for system: ElectricalSystem) -> some 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)
@@ -103,68 +191,47 @@ struct SystemsView: View {
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundColor(.secondary.opacity(0.6))
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteSystems)
}
.accessibilityIdentifier("systems-list")
}
}
.navigationTitle("Systems")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
createNewSystem()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $systemNavigationTarget) { target in
LoadsView(
system: target.system,
presentSystemEditorOnAppear: target.presentSystemEditor,
loadToOpenOnAppear: target.loadToOpenOnAppear
)
}
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponentFromLibrary(item)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(unitSettings)
}
}
private var systemsEmptyState: some View {
SystemsOnboardingView { name in
createOnboardingSystem(named: name)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func createNewSystem() {
let system = makeSystem()
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
"source": "toolbar"
]
)
navigateToSystem(
system,
presentSystemEditor: true,
loadToOpen: nil,
source: "created"
)
}
private func createNewSystem(named name: String) {
let system = makeSystem(preferredName: name)
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
"source": "named"
]
)
navigateToSystem(
system,
presentSystemEditor: true,
loadToOpen: nil,
source: "created-named"
)
}
private func createOnboardingSystem(named name: String) {
@@ -172,10 +239,29 @@ struct SystemsView: View {
preferredName: name,
colorName: randomSystemColorName()
)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "onboarding"
)
}
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
private func navigateToSystem(
_ system: ElectricalSystem,
presentSystemEditor: Bool,
loadToOpen: SavedLoad?,
animated: Bool = true,
source: String = "programmatic"
) {
AnalyticsTracker.log(
"System Opened",
properties: [
"name": system.name,
"source": source,
"loads": loads(for: system).count
]
)
let target = SystemNavigationTarget(
system: system,
presentSystemEditor: presentSystemEditor,
@@ -219,10 +305,45 @@ 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,
source: "auto"
)
}
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
"source": "library"
]
)
let load = createLoad(from: item, in: system)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
AnalyticsTracker.log(
"Library Load Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: load,
animated: false,
source: "library"
)
}
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
@@ -294,9 +415,16 @@ struct SystemsView: View {
}
private func deleteSystems(offsets: IndexSet) {
let systemsToDelete = offsets.map { systems[$0] }
withAnimation {
for index in offsets {
let system = systems[index]
for system in systemsToDelete {
AnalyticsTracker.log(
"System Deleted",
properties: [
"name": system.name,
"loads": loads(for: system).count
]
)
deleteLoads(for: system)
modelContext.delete(system)
}
@@ -307,6 +435,14 @@ struct SystemsView: View {
let descriptor = FetchDescriptor<SavedLoad>()
if let loads = try? modelContext.fetch(descriptor) {
for load in loads where load.system == system {
AnalyticsTracker.log(
"Load Deleted",
properties: [
"name": load.name,
"system": system.name,
"source": "system-delete"
]
)
modelContext.delete(load)
}
}
@@ -383,22 +519,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())
}

View File

@@ -7,38 +7,61 @@ import Foundation
import SwiftData
enum UITestSampleData {
static let argument = "--uitest-sample-data"
static let sampleArgument = "--uitest-sample-data"
static let resetArgument = "--uitest-reset-data"
static func prepareIfNeeded(container: ModelContainer) {
static func handleLaunchArguments(container: ModelContainer) {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
let arguments = ProcessInfo.processInfo.arguments
NSLog("UITestSampleData arguments: %@", arguments.joined(separator: ", "))
guard arguments.contains(sampleArgument) || arguments.contains(resetArgument) else { return }
let context = ModelContext(container)
do {
if arguments.contains(resetArgument) {
NSLog("UITestSampleData resetting data store")
try clearExistingData(in: context)
}
if arguments.contains(sampleArgument) {
NSLog("UITestSampleData seeding sample data")
if !arguments.contains(resetArgument) {
try clearExistingData(in: context)
}
try seedSampleData(in: context)
}
if context.hasChanges {
try context.save()
NSLog("UITestSampleData save completed")
}
} catch {
assertionFailure("Failed to seed UI test sample data: \(error)")
assertionFailure("Failed to prepare UI test data: \(error)")
}
#endif
}
}
#if DEBUG
private extension UITestSampleData {
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 +146,79 @@ private extension UITestSampleData {
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
let vanHouseBattery = SavedBattery(
name: String(localized: "sample.battery.rv.name", comment: "Sample data battery name for the adventure van system"),
nominalVoltage: 12.8,
capacityAmpHours: 200.0,
chemistry: .lithiumIronPhosphate,
chargeVoltage: 14.4,
cutOffVoltage: 10.8,
minimumTemperatureCelsius: -20,
maximumTemperatureCelsius: 60,
iconName: "battery.100.bolt",
colorName: "purple",
system: adventureVan
)
vanHouseBattery.timestamp = Date(timeIntervalSinceReferenceDate: 1250)
let workshopBackupBattery = SavedBattery(
name: String(localized: "sample.battery.workshop.name", comment: "Sample data battery name for the workshop system"),
nominalVoltage: 24.0,
capacityAmpHours: 100.0,
chemistry: .agm,
chargeVoltage: 28.8,
cutOffVoltage: 21.0,
minimumTemperatureCelsius: -10,
maximumTemperatureCelsius: 50,
iconName: "battery.75",
colorName: "gray",
system: workshopBench
)
workshopBackupBattery.timestamp = Date(timeIntervalSinceReferenceDate: 2300)
[vanHouseBattery, workshopBackupBattery].forEach { context.insert($0) }
let shoreCharger = SavedCharger(
name: String(localized: "sample.charger.shore.name", comment: "Sample data name for a shore power charger"),
inputVoltage: 230.0,
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

View File

@@ -46,9 +46,15 @@ class UnitSystemSettings: ObservableObject {
UserDefaults.standard.set(unitSystem.rawValue, forKey: "unitSystem")
}
}
@Published var isProUnlocked: Bool {
didSet {
UserDefaults.standard.set(isProUnlocked, forKey: "isProUnlocked")
}
}
init() {
let savedSystem = UserDefaults.standard.string(forKey: "unitSystem") ?? UnitSystem.metric.rawValue
self.unitSystem = UnitSystem(rawValue: savedSystem) ?? .metric
self.isProUnlocked = UserDefaults.standard.bool(forKey: "isProUnlocked")
}
}

View File

@@ -1,8 +1,138 @@
// Keys
"Add your first component" = "Erstelle deine erste Komponente";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
"Browse" = "Durchsuchen";
"Browse Library" = "Bibliothek durchsuchen";
"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern";
"Cancel" = "Abbrechen";
"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus.";
"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.";
"Close" = "Schließen";
"Color" = "Farbe";
"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar verwalte hier deine elektrischen Systeme und Verteilungen.";
"Component Library" = "Komponentenbibliothek";
"Components" = "Verbraucher";
"Create Component" = "Komponente erstellen";
"Create System" = "System erstellen";
"Create your first system" = "Erstelle dein erstes System";
"Current" = "Strom";
"Current Units" = "Aktuelle Einheiten";
"Details" = "Details";
"Details coming soon" = "Details folgen in Kürze";
"Edit Current" = "Strom bearbeiten";
"Edit Length" = "Länge bearbeiten";
"Edit Power" = "Leistung bearbeiten";
"Edit Voltage" = "Spannung bearbeiten";
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
"Enter length in %@" = "Gib die Länge in %@ ein";
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
"FUSE" = "SICHERUNG";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
"Icon" = "Symbol";
"Important:" = "Wichtig:";
"Length" = "Länge";
"Length:" = "Länge:";
"Load Library" = "Verbraucher-bibliothek";
"Loading components" = "Komponenten werden geladen";
"New Load" = "Neuer Verbraucher";
"No components available" = "Keine Komponenten verfügbar";
"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher.";
"No matches" = "Keine Treffer";
"Power" = "Leistung";
"Preview" = "Vorschau";
"Retry" = "Erneut versuchen";
"Safety Disclaimer" = "Sicherheitshinweis";
"Save" = "Speichern";
"Search components" = "Komponenten suchen";
"Settings" = "Einstellungen";
"System" = "System";
"System Name" = "Systemname";
"System View" = "Systemansicht";
"Systems" = "Systeme";
"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.";
"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen.";
"Unable to load components" = "Komponenten konnten nicht geladen werden";
"Unit System" = "Einheitensystem";
"Units" = "Einheiten";
"VoltPlan Library" = "VoltPlan-Bibliothek";
"Voltage" = "Spannung";
"WIRE" = "KABEL";
"Wire Cross-Section:" = "Kabelquerschnitt:";
"affiliate.button.review_parts" = "Bauteile prüfen";
"affiliate.description.with_link" = "Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen.";
"affiliate.description.without_link" = "Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen.";
"affiliate.disclaimer" = "Käufe über Affiliate-Links können VoltPlan unterstützen.";
"battery.bank.badge.capacity" = "Kapazität";
"battery.bank.badge.energy" = "Energie";
"battery.bank.badge.voltage" = "Spannung";
"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt";
"battery.bank.banner.voltage" = "Spannungsabweichung erkannt";
"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren.";
"battery.bank.empty.title" = "Noch keine Batterien";
"battery.bank.header.title" = "Batterien";
"battery.bank.metric.capacity" = "Kapazität";
"battery.bank.metric.count" = "Batterien";
"battery.bank.metric.energy" = "Energie";
"battery.bank.metric.usable_capacity" = "Nutzbare Kapazität";
"battery.bank.metric.usable_energy" = "Nutzbare Energie";
"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung.";
"battery.bank.status.capacity.title" = "Kapazitätsabweichung";
"battery.bank.status.dismiss" = "Verstanden";
"battery.bank.status.multiple.batteries" = "%d Batterien";
"battery.bank.status.single.battery" = "Eine Batterie";
"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen.";
"battery.bank.status.voltage.title" = "Spannungsabweichung";
"battery.bank.warning.capacity.short" = "Kapazität";
"battery.bank.warning.voltage.short" = "Spannung";
"battery.editor.advanced.usable_capacity.footer_default" = "Standardwert %@ basierend auf der Chemie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@.";
"battery.editor.advanced.charge_voltage.helper" = "Lege die maximal empfohlene Ladespannung fest.";
"battery.editor.advanced.cutoff_voltage.helper" = "Lege die minimale sichere Entladespannung fest.";
"battery.editor.advanced.temperature_range.helper" = "Definiere den empfohlenen Betriebstemperaturbereich.";
"battery.editor.alert.charge_voltage.message" = "Gib die Ladespannung in Volt (V) ein.";
"battery.editor.alert.charge_voltage.placeholder" = "Ladespannung";
"battery.editor.alert.charge_voltage.title" = "Ladespannung bearbeiten";
"battery.editor.alert.cutoff_voltage.message" = "Gib die Abschaltspannung in Volt (V) ein.";
"battery.editor.alert.cutoff_voltage.placeholder" = "Abschaltspannung";
"battery.editor.alert.cutoff_voltage.title" = "Abschaltspannung bearbeiten";
"battery.editor.alert.maximum_temperature.message" = "Gib die Höchsttemperatur in Grad Celsius (\u00B0C) ein.";
"battery.editor.alert.maximum_temperature.placeholder" = "Höchsttemperatur (\u00B0C)";
"battery.editor.alert.maximum_temperature.title" = "Höchsttemperatur bearbeiten";
"battery.editor.alert.minimum_temperature.message" = "Gib die Mindesttemperatur in Grad Celsius (\u00B0C) ein.";
"battery.editor.alert.minimum_temperature.placeholder" = "Mindesttemperatur (\u00B0C)";
"battery.editor.alert.minimum_temperature.title" = "Mindesttemperatur bearbeiten";
"battery.editor.alert.cancel" = "Abbrechen";
"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben";
"battery.editor.alert.capacity.placeholder" = "Kapazität";
"battery.editor.alert.capacity.title" = "Kapazität bearbeiten";
"battery.editor.alert.save" = "Speichern";
"battery.editor.alert.usable_capacity.message" = "Nutzbare Kapazität in Prozent (%) eingeben";
"battery.editor.alert.usable_capacity.placeholder" = "Nutzbare Kapazität (%)";
"battery.editor.alert.usable_capacity.title" = "Nutzbare Kapazität bearbeiten";
"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
"battery.editor.alert.voltage.placeholder" = "Spannung";
"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten";
"battery.editor.button.reset_default" = "Zurücksetzen";
"battery.editor.cancel" = "Abbrechen";
"battery.editor.default_name" = "Neue Batterie";
"battery.editor.field.chemistry" = "Chemie";
"battery.editor.field.name" = "Name";
"battery.editor.placeholder.name" = "Hausbank";
"battery.editor.save" = "Speichern";
"battery.editor.section.advanced" = "Erweitert";
"battery.editor.section.summary" = "Übersicht";
"battery.editor.slider.capacity" = "Kapazität";
"battery.editor.slider.charge_voltage" = "Ladespannung";
"battery.editor.slider.cutoff_voltage" = "Abschaltspannung";
"battery.editor.slider.temperature_range" = "Temperaturbereich";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.usable_capacity" = "Nutzbare Kapazität (%)";
"battery.editor.slider.voltage" = "Nennspannung";
"battery.editor.title" = "Batterie einrichten";
"battery.onboarding.subtitle" = "Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten.";
"battery.onboarding.title" = "Füge deine erste Batterie hinzu";
"battery.overview.empty.create" = "Batterie hinzufügen";
"bom.accessibility.mark.complete" = "Markiere %@ als erledigt";
"bom.accessibility.mark.incomplete" = "Markiere %@ als unerledigt";
"bom.fuse.detail" = "Inline-Halter und %dA-Sicherung";
@@ -14,11 +144,93 @@
"bom.navigation.title.system" = "Stückliste %@";
"bom.size.unknown" = "Größe offen";
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
"bom.export.pdf.button" = "PDF exportieren";
"bom.export.pdf.error.title" = "Export fehlgeschlagen";
"bom.export.pdf.error.empty" = "Füge vor dem Export mindestens eine Komponente hinzu.";
"bom.pdf.header.title" = "System-Stückliste";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Einheitensystem: %@";
"bom.pdf.placeholder.empty" = "Keine Komponenten verfügbar.";
"bom.pdf.page.number" = "Seite %d";
"bom.category.components.title" = "Komponenten & Ladegeräte";
"bom.category.components.subtitle" = "Hauptverbraucher, Regler und Ladehardware.";
"bom.category.batteries.title" = "Batterien";
"bom.category.batteries.subtitle" = "Hausspeicher und Batteriebänke.";
"bom.category.cables.title" = "Kabel";
"bom.category.cables.subtitle" = "Passende Leitungen für jede Strecke.";
"bom.category.fuses.title" = "Sicherungen";
"bom.category.fuses.subtitle" = "Stromkreisschutz und Halter.";
"bom.category.accessories.title" = "Zubehör";
"bom.category.accessories.subtitle" = "Sicherungen, Kabelschuhe und weiteres Montagematerial.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"cable.pro.privacy.label" = "Datenschutz";
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
"cable.pro.terms.label" = "Nutzungsbedingungen";
"cable.pro.terms.url" = "https://voltplan.app/de/agb";
"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
"calculator.advanced.duty_cycle.title" = "Einschaltdauer";
"calculator.advanced.section.title" = "Erweitert";
"calculator.advanced.usage_hours.helper" = "Stunden pro Tag, in denen die Last eingeschaltet ist.";
"calculator.advanced.usage_hours.title" = "Tägliche Laufzeit";
"calculator.advanced.usage_hours.unit" = "h/Tag";
"calculator.alert.duty_cycle.message" = "Einschaltdauer als Prozent (0-100 %) eingeben.";
"calculator.alert.duty_cycle.placeholder" = "Einschaltdauer";
"calculator.alert.duty_cycle.title" = "Einschaltdauer bearbeiten";
"calculator.alert.usage_hours.message" = "Stunden pro Tag eingeben, in denen die Last aktiv ist.";
"calculator.alert.usage_hours.placeholder" = "Tägliche Laufzeit";
"calculator.alert.usage_hours.title" = "Tägliche Laufzeit bearbeiten";
"charger.default.new" = "Neues Ladegerät";
"charger.editor.alert.cancel" = "Abbrechen";
"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben";
"charger.editor.alert.current.title" = "Ladestrom bearbeiten";
"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten";
"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten";
"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben";
"charger.editor.alert.power.placeholder" = "Leistung";
"charger.editor.alert.power.title" = "Ladeleistung bearbeiten";
"charger.editor.alert.save" = "Speichern";
"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten";
"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird";
"charger.editor.appearance.title" = "Ladegerät-Darstellung";
"charger.editor.default_name" = "Neues Ladegerät";
"charger.editor.field.current" = "Ladestrom";
"charger.editor.field.input_voltage" = "Eingangsspannung";
"charger.editor.field.name" = "Name";
"charger.editor.field.output_voltage" = "Ausgangsspannung";
"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.placeholder.name" = "Werkstattladegerät";
"charger.editor.section.electrical" = "Elektrik";
"charger.editor.section.power" = "Ladeausgang";
"charger.editor.title" = "Ladegerät";
"chargers.badge.current" = "Strom";
"chargers.badge.input" = "Eingang";
"chargers.badge.output" = "Ausgang";
"chargers.badge.power" = "Leistung";
"chargers.onboarding.primary" = "Ladegerät erstellen";
"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten.";
"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu";
"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar.";
"chargers.summary.metric.count" = "Ladegeräte";
"chargers.summary.metric.current" = "Ladestrom";
"chargers.summary.metric.output" = "Spannung";
"chargers.summary.metric.power" = "Ladeleistung";
"chargers.summary.title" = "Ladeübersicht";
"chargers.title" = "Ladegeräte für %@";
"component.fallback.name" = "Komponente";
"default.load.library" = "Bibliothekslast";
"default.load.name" = "Mein Verbraucher";
"default.load.unnamed" = "Unbenannter Verbraucher";
"default.load.new" = "Neuer Verbraucher";
"default.load.unnamed" = "Unbenannter Verbraucher";
"default.system.name" = "Mein System";
"default.system.new" = "Neues System";
"editor.load.name_field" = "Name des Verbrauchers";
@@ -27,156 +239,153 @@
"editor.system.location.optional" = "Standort (optional)";
"editor.system.name_field" = "Name des Systems";
"editor.system.title" = "System bearbeiten";
"loads.library.button" = "Bibliothek";
"loads.metric.cable" = "Schnitt";
"loads.metric.fuse" = "Sicherung";
"loads.metric.length" = "Länge";
"loads.onboarding.subtitle" = "Statte dein System mit Verbrauchern aus und lass **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen übernehmen.";
"loads.onboarding.title" = "Erstelle deinen ersten Verbraucher";
"loads.overview.empty.create" = "Verbraucher hinzufügen";
"loads.overview.empty.library" = "Bibliothek durchsuchen";
"loads.overview.empty.message" = "Füge einen Verbraucher hinzu, um dein System einzurichten.";
"loads.overview.header.title" = "Verbraucher";
"loads.overview.metric.count" = "Verbraucher";
"loads.overview.metric.current" = "Strom";
"loads.overview.metric.power" = "Leistung";
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen";
"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.plural" = "Verbraucher";
"loads.overview.status.missing_details.singular" = "Verbraucher";
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
"overview.chargers.empty.create" = "Ladegerät hinzufügen";
"overview.chargers.empty.subtitle" = "Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen.";
"overview.chargers.empty.title" = "Noch keine Ladegeräte konfiguriert";
"overview.chargers.header.title" = "Ladegeräte";
"overview.loads.empty.subtitle" = "Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten.";
"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet";
"overview.runtime.subtitle" = "Bei dauerhafter Vollast";
"overview.runtime.title" = "Geschätzte Laufzeit";
"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen.";
"overview.system.header.title" = "Systemübersicht";
"overview.bom.title" = "Stückliste";
"overview.bom.subtitle" = "Tippe, um Komponenten zu prüfen";
"overview.bom.unavailable" = "Füge Verbraucher hinzu, um Komponenten zu erzeugen.";
"overview.bom.placeholder.short" = "Verbraucher hinzufügen";
"overview.chargetime.title" = "Geschätzte Ladezeit";
"overview.chargetime.subtitle" = "Bei kombinierter Laderate";
"overview.chargetime.unavailable" = "Füge Ladegeräte und Batteriekapazität hinzu, um eine Schätzung zu erhalten.";
"overview.chargetime.placeholder.short" = "Ladegeräte hinzufügen";
"overview.goal.prefix" = "Ziel";
"overview.goal.label" = "Ziel %@";
"overview.goal.clear" = "Ziel entfernen";
"overview.goal.cancel" = "Abbrechen";
"overview.goal.save" = "Speichern";
"overview.runtime.goal.title" = "Laufzeit-Ziel";
"overview.chargetime.goal.title" = "Ladezeit-Ziel";
"overview.runtime.placeholder.short" = "Kapazität hinzufügen";
"sample.battery.rv.name" = "LiFePO4-Bordbatterie";
"sample.battery.workshop.name" = "Werkbank-Reservebatterie";
"sample.charger.dcdc.name" = "DC-DC-Ladegerät";
"sample.charger.shore.name" = "Landstrom-Ladegerät";
"sample.charger.workbench.name" = "Werkbank-Ladegerät";
"sample.load.charger.name" = "Werkzeugladegerät";
"sample.load.compressor.name" = "Luftkompressor";
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
"sample.load.lighting.name" = "LED-Streifen";
"sample.system.rv.location" = "12V Wohnstromkreis";
"sample.system.rv.name" = "Abenteuer-Van";
"sample.system.workshop.location" = "Werkzeugecke";
"sample.system.workshop.name" = "Werkbank";
"slider.button.ampere" = "Ampere";
"slider.button.watt" = "Watt";
"slider.current.title" = "Strom";
"slider.length.title" = "Kabellänge (%@)";
"slider.power.title" = "Leistung";
"slider.voltage.title" = "Spannung";
"system.list.no.components" = "Noch keine Verbraucher";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
"sample.system.rv.name" = "Abenteuer-Van";
"sample.system.rv.location" = "12V Wohnstromkreis";
"sample.system.workshop.name" = "Werkbank";
"sample.system.workshop.location" = "Werkzeugecke";
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
"sample.load.compressor.name" = "Luftkompressor";
"sample.load.charger.name" = "Werkzeugladegerät";
"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus";
"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer";
"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot";
"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft";
"system.icon.keywords.ferry" = "fähre, schiff";
"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge";
"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage";
"system.icon.keywords.tent" = "camp, camping, zelt, outdoor";
"system.icon.keywords.solar" = "solar, sonne, pv";
"system.icon.keywords.battery" = "batterie, speicher, akku";
"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum";
"system.icon.keywords.computer" = "computer, elektronik, labor, technik";
"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt";
"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt";
"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei";
"system.icon.keywords.light" = "licht, beleuchtung, lampe";
"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot";
"system.icon.keywords.bolt" = "strom, power, elektrisch, spannung";
"system.icon.keywords.plug" = "stecker, netzstecker";
"system.icon.keywords.engine" = "motor, generator, antrieb";
"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin";
"system.icon.keywords.water" = "wasser, pumpe, tank";
"system.icon.keywords.heat" = "heizung, heizer, ofen";
"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren";
"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage";
"system.icon.keywords.climate" = "klima, hvac, temperatur";
// Direct strings
"Systems" = "Systeme";
"System" = "System";
"System View" = "Systemansicht";
"System Name" = "Systemname";
"Create System" = "System erstellen";
"Create your first system" = "Erstelle dein erstes System";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
"Add your first component" = "Erstelle deine erste Komponente";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
"Create Component" = "Komponente erstellen";
"Browse Library" = "Bibliothek durchsuchen";
"Browse" = "Durchsuchen";
"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern";
"Component Library" = "Komponentenbibliothek";
"Details coming soon" = "Details folgen in Kürze";
"Components" = "Verbraucher";
"FUSE" = "SICHERUNG";
"WIRE" = "KABEL";
"Current" = "Strom";
"Power" = "Leistung";
"Voltage" = "Spannung";
"Length" = "Länge";
"Length:" = "Länge:";
"Wire Cross-Section:" = "Kabelquerschnitt:";
"Current Units" = "Aktuelle Einheiten";
"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus.";
"Unit System" = "Einheitensystem";
"Units" = "Einheiten";
"Settings" = "Einstellungen";
"Close" = "Schließen";
"Cancel" = "Abbrechen";
"Save" = "Speichern";
"Retry" = "Erneut versuchen";
"Loading components" = "Komponenten werden geladen";
"Unable to load components" = "Komponenten konnten nicht geladen werden";
"No components available" = "Keine Komponenten verfügbar";
"No matches" = "Keine Treffer";
"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.";
"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen.";
"Search components" = "Komponenten suchen";
"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher.";
"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar verwalte hier deine elektrischen Systeme und Verteilungen.";
"Load Library" = "Verbraucher-bibliothek";
"Safety Disclaimer" = "Sicherheitshinweis";
"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.";
"Important:" = "Wichtig:";
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";
"• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen";
"Enter length in %@" = "Gib die Länge in %@ ein";
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
"Edit Length" = "Länge bearbeiten";
"Edit Voltage" = "Spannung bearbeiten";
"Edit Current" = "Strom bearbeiten";
"Edit Power" = "Leistung bearbeiten";
"Preview" = "Vorschau";
"Details" = "Details";
"Icon" = "Symbol";
"Color" = "Farbe";
"VoltPlan Library" = "VoltPlan-Bibliothek";
"New Load" = "Neuer Verbraucher";
"tab.components" = "Verbraucher";
"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren";
"system.icon.keywords.computer" = "computer, elektronik, labor, technik";
"system.icon.keywords.engine" = "motor, generator, antrieb";
"system.icon.keywords.ferry" = "fähre, schiff";
"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin";
"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt";
"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei";
"system.icon.keywords.heat" = "heizung, heizer, ofen";
"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge";
"system.icon.keywords.light" = "licht, beleuchtung, lampe";
"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft";
"system.icon.keywords.plug" = "stecker, netzstecker";
"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus";
"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum";
"system.icon.keywords.solar" = "solar, sonne, pv";
"system.icon.keywords.tent" = "camp, camping, zelt, outdoor";
"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt";
"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer";
"system.icon.keywords.water" = "wasser, pumpe, tank";
"system.list.no.components" = "Noch keine Verbraucher";
"tab.batteries" = "Batterien";
"tab.chargers" = "Ladegeräte";
"battery.bank.header.title" = "Batteriebank";
"battery.bank.metric.count" = "Batterien";
"battery.bank.metric.capacity" = "Kapazität";
"battery.bank.metric.energy" = "Energie";
"battery.bank.badge.voltage" = "Spannung";
"battery.bank.badge.capacity" = "Kapazität";
"battery.bank.badge.energy" = "Energie";
"battery.bank.banner.voltage" = "Spannungsabweichung erkannt";
"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt";
"battery.bank.empty.title" = "Noch keine Batterien";
"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren.";
"battery.bank.status.dismiss" = "Verstanden";
"battery.bank.status.single.battery" = "Eine Batterie";
"battery.bank.status.multiple.batteries" = "%d Batterien";
"battery.bank.status.voltage.title" = "Spannungsabweichung";
"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen.";
"battery.bank.status.capacity.title" = "Kapazitätsabweichung";
"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung.";
"battery.editor.title" = "Batterie einrichten";
"battery.editor.cancel" = "Abbrechen";
"battery.editor.save" = "Speichern";
"battery.editor.field.name" = "Name";
"battery.editor.placeholder.name" = "Hausbank";
"battery.editor.field.chemistry" = "Chemie";
"battery.editor.section.summary" = "Übersicht";
"battery.editor.slider.voltage" = "Nennspannung";
"battery.editor.slider.capacity" = "Kapazität";
"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten";
"battery.editor.alert.voltage.placeholder" = "Spannung";
"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
"battery.editor.alert.capacity.title" = "Kapazität bearbeiten";
"battery.editor.alert.capacity.placeholder" = "Kapazität";
"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben";
"battery.editor.alert.cancel" = "Abbrechen";
"battery.editor.alert.save" = "Speichern";
"battery.editor.default_name" = "Neue Batterie";
"chargers.title" = "Ladegeräte für %@";
"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar.";
"tab.components" = "Verbraucher";
"tab.overview" = "Übersicht";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
"settings.pro.cta.description" = "Cable PRO ermöglicht detailliertere Einstellungen für Verbraucher, Batterien und Ladegeräte.";
"settings.pro.cta.button" = "Cable PRO abonnieren";
"settings.pro.renewal.date" = "Nächste Verlängerung am %@.";
"settings.pro.trial.remaining" = "%@ verbleibend in der Testphase.";
"settings.pro.trial.today" = "Die Testphase endet heute.";
"settings.pro.instructions" = "Verwalte oder kündige dein Abonnement im App Store.";
"settings.pro.manage.button" = "Abonnement verwalten";
"settings.pro.manage.url" = "https://apps.apple.com/account/subscriptions";
"settings.pro.day.one" = "%@ Tag";
"settings.pro.day.other" = "%@ Tage";
"cable.pro.terms.label" = "AGB";
"cable.pro.privacy.label" = "Datenschutz";
"cable.pro.terms.url" = "https://voltplan.app/terms";
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
"cable.pro.paywall.title" = "Cable PRO";
"cable.pro.paywall.subtitle" = "Cable PRO bietet mehr Konfigurationsoptionen für Verbraucher, Batterien und Ladegeräte.";
"cable.pro.feature.dutyCycle" = "Kabelberechnungen mit Einschaltdauer";
"cable.pro.feature.batteryCapacity" = "Verfügbare Batteriekapazität konfigurieren";
"cable.pro.feature.usageBased" = "Nutzungsbasierte Berechnungen";
"cable.pro.button.unlock" = "Jetzt freischalten";
"cable.pro.button.freeTrial" = "Kostenlose Testphase starten";
"cable.pro.button.unlocked" = "Bereits aktiviert";
"cable.pro.restore.button" = "Käufe wiederherstellen";
"cable.pro.alert.success.title" = "Cable PRO aktiviert";
"cable.pro.alert.success.body" = "Danke für deine Unterstützung!";
"cable.pro.alert.pending.title" = "Kauf ausstehend";
"cable.pro.alert.pending.body" = "Dein Kauf wartet auf Bestätigung.";
"cable.pro.alert.restored.title" = "Käufe wiederhergestellt";
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
"generic.ok" = "OK";
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
"cable.pro.subscription.renews" = "Verlängert sich %@.";
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
"cable.pro.duration.day.singular" = "jeden Tag";
"cable.pro.duration.day.plural" = "alle %@ Tage";
"cable.pro.duration.week.singular" = "jede Woche";
"cable.pro.duration.week.plural" = "alle %@ Wochen";
"cable.pro.duration.month.singular" = "monatlich";
"cable.pro.duration.month.plural" = "alle %@ Monate";
"cable.pro.duration.year.singular" = "jährlich";
"cable.pro.duration.year.plural" = "alle %@ Jahre";
"cable.pro.trial.duration.day.singular" = "%@-tägige";
"cable.pro.trial.duration.day.plural" = "%@-tägige";
"cable.pro.trial.duration.week.singular" = "%@-wöchige";
"cable.pro.trial.duration.week.plural" = "%@-wöchige";
"cable.pro.trial.duration.month.singular" = "%@-monatige";
"cable.pro.trial.duration.month.plural" = "%@-monatige";
"cable.pro.trial.duration.year.singular" = "%@-jährige";
"cable.pro.trial.duration.year.plural" = "%@-jährige";
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
"• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen";
"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";

View File

@@ -14,6 +14,33 @@
"bom.navigation.title.system" = "Lista de materiales %@";
"bom.size.unknown" = "Tamaño por definir";
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
"bom.export.pdf.button" = "Exportar PDF";
"bom.export.pdf.error.title" = "Exportación fallida";
"bom.export.pdf.error.empty" = "Agrega al menos un componente antes de exportar.";
"bom.pdf.header.title" = "Lista de materiales del sistema";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Sistema de unidades: %@";
"bom.pdf.placeholder.empty" = "No hay componentes disponibles.";
"bom.pdf.page.number" = "Página %d";
"bom.category.components.title" = "Componentes y cargadores";
"bom.category.components.subtitle" = "Dispositivos principales, controladores y equipos de carga.";
"bom.category.batteries.title" = "Baterías";
"bom.category.batteries.subtitle" = "Bancos domésticos y almacenamiento.";
"bom.category.cables.title" = "Cables";
"bom.category.cables.subtitle" = "Tendidos dimensionados para cada circuito.";
"bom.category.fuses.title" = "Fusibles";
"bom.category.fuses.subtitle" = "Protección de circuitos y portafusibles.";
"bom.category.accessories.title" = "Accesorios";
"bom.category.accessories.subtitle" = "Fusibles, terminales y piezas de soporte.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"component.fallback.name" = "Componente";
"default.load.library" = "Carga de la biblioteca";
"default.load.name" = "Mi carga";
@@ -33,6 +60,18 @@
"slider.length.title" = "Longitud del cable (%@)";
"slider.power.title" = "Potencia";
"slider.voltage.title" = "Voltaje";
"calculator.advanced.section.title" = "Configuración avanzada";
"calculator.advanced.duty_cycle.title" = "Ciclo de trabajo";
"calculator.advanced.duty_cycle.helper" = "Porcentaje del tiempo activo en el que la carga consume energía.";
"calculator.advanced.usage_hours.title" = "Tiempo encendido diario";
"calculator.advanced.usage_hours.helper" = "Horas por día que la carga permanece encendida.";
"calculator.advanced.usage_hours.unit" = "h/día";
"calculator.alert.duty_cycle.title" = "Editar ciclo de trabajo";
"calculator.alert.duty_cycle.placeholder" = "Ciclo de trabajo";
"calculator.alert.duty_cycle.message" = "Introduce el porcentaje de ciclo de trabajo (0-100%).";
"calculator.alert.usage_hours.title" = "Editar tiempo encendido diario";
"calculator.alert.usage_hours.placeholder" = "Tiempo encendido diario";
"calculator.alert.usage_hours.message" = "Introduce las horas por día que la carga está activa.";
"system.list.no.components" = "Aún no hay componentes";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Métrico (mm², m)";
@@ -135,15 +174,68 @@
"VoltPlan Library" = "Biblioteca de VoltPlan";
"New Load" = "Carga nueva";
"tab.overview" = "Resumen";
"tab.components" = "Componentes";
"tab.batteries" = "Baterías";
"tab.chargers" = "Cargadores";
"loads.overview.header.title" = "Resumen de cargas";
"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";
"loads.overview.status.missing_details.plural" = "cargas";
"loads.overview.status.missing_details.banner" = "Completa la configuración de tus cargas";
"loads.metric.fuse" = "Fusible";
"loads.metric.cable" = "Cable";
"loads.metric.length" = "Longitud";
"overview.system.header.title" = "Resumen del sistema";
"overview.loads.empty.title" = "Aún no hay cargas configuradas";
"overview.loads.empty.subtitle" = "Añade cargas para obtener recomendaciones de cables y fusibles adaptadas a este sistema.";
"overview.runtime.title" = "Autonomía estimada";
"overview.runtime.subtitle" = "Con la carga actual";
"overview.runtime.unavailable" = "Añade capacidad de batería y potencia de carga para estimar la autonomía.";
"overview.bom.title" = "Lista de materiales";
"overview.bom.subtitle" = "Pulsa para revisar los componentes";
"overview.bom.unavailable" = "Añade cargas para generar componentes.";
"overview.bom.placeholder.short" = "Añadir cargas";
"overview.chargetime.title" = "Tiempo de carga estimado";
"overview.chargetime.subtitle" = "Con la tasa de carga combinada";
"overview.chargetime.unavailable" = "Añade cargadores y capacidad de batería para calcularlo.";
"overview.chargetime.placeholder.short" = "Añadir cargadores";
"overview.goal.prefix" = "Objetivo";
"overview.goal.label" = "Objetivo %@";
"overview.goal.clear" = "Eliminar objetivo";
"overview.goal.cancel" = "Cancelar";
"overview.goal.save" = "Guardar";
"overview.runtime.goal.title" = "Objetivo de autonomía";
"overview.chargetime.goal.title" = "Objetivo de carga";
"overview.runtime.placeholder.short" = "Añadir capacidad";
"battery.bank.warning.voltage.short" = "Voltaje";
"battery.bank.warning.capacity.short" = "Capacidad";
"battery.bank.header.title" = "Banco de baterías";
"battery.bank.metric.count" = "Baterías";
"battery.bank.metric.capacity" = "Capacidad";
"battery.bank.metric.energy" = "Energía";
"battery.bank.metric.usable_capacity" = "Capacidad utilizable";
"battery.bank.metric.usable_energy" = "Energía utilizable";
"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";
"overview.chargers.header.title" = "Resumen de cargadores";
"overview.chargers.empty.title" = "Aún no hay cargadores configurados";
"overview.chargers.empty.subtitle" = "Añade cargadores de toma de puerto, DC-DC o solares para conocer tu capacidad de carga.";
"overview.chargers.empty.create" = "Agregar cargador";
"battery.bank.badge.capacity" = "Capacidad";
"battery.bank.badge.energy" = "Energía";
"battery.bank.banner.voltage" = "Se detectó un desajuste de voltaje";
@@ -167,15 +259,95 @@
"battery.editor.section.summary" = "Resumen";
"battery.editor.slider.voltage" = "Voltaje nominal";
"battery.editor.slider.capacity" = "Capacidad";
"battery.editor.slider.usable_capacity" = "Capacidad utilizable (%)";
"battery.editor.slider.charge_voltage" = "Voltaje de carga";
"battery.editor.slider.cutoff_voltage" = "Voltaje de corte";
"battery.editor.slider.temperature_range" = "Rango de temperatura";
"battery.editor.slider.temperature_range.min" = "Mínimo";
"battery.editor.slider.temperature_range.max" = "Máximo";
"battery.editor.section.advanced" = "Avanzado";
"battery.editor.button.reset_default" = "Restablecer";
"battery.editor.advanced.usable_capacity.footer_default" = "Valor predeterminado %@ basado en la química.";
"battery.editor.advanced.usable_capacity.footer_override" = "Sobrescritura activa. El valor predeterminado por química sigue siendo %@.";
"battery.editor.advanced.charge_voltage.helper" = "Establece el voltaje máximo de carga recomendado.";
"battery.editor.advanced.cutoff_voltage.helper" = "Establece el voltaje mínimo seguro de descarga.";
"battery.editor.advanced.temperature_range.helper" = "Define el rango de temperatura de operación recomendado.";
"battery.editor.alert.voltage.title" = "Editar voltaje nominal";
"battery.editor.alert.voltage.placeholder" = "Voltaje";
"battery.editor.alert.voltage.message" = "Introduce el voltaje en voltios (V)";
"battery.editor.alert.capacity.title" = "Editar capacidad";
"battery.editor.alert.capacity.placeholder" = "Capacidad";
"battery.editor.alert.capacity.message" = "Introduce la capacidad en amperios-hora (Ah)";
"battery.editor.alert.usable_capacity.title" = "Editar capacidad utilizable";
"battery.editor.alert.usable_capacity.placeholder" = "Capacidad utilizable (%)";
"battery.editor.alert.usable_capacity.message" = "Introduce el porcentaje de capacidad utilizable (%)";
"battery.editor.alert.charge_voltage.title" = "Editar voltaje de carga";
"battery.editor.alert.charge_voltage.placeholder" = "Voltaje de carga";
"battery.editor.alert.charge_voltage.message" = "Introduce el voltaje de carga en voltios (V).";
"battery.editor.alert.cutoff_voltage.title" = "Editar voltaje de corte";
"battery.editor.alert.cutoff_voltage.placeholder" = "Voltaje de corte";
"battery.editor.alert.cutoff_voltage.message" = "Introduce el voltaje de corte en voltios (V).";
"battery.editor.alert.minimum_temperature.title" = "Editar temperatura mínima";
"battery.editor.alert.minimum_temperature.placeholder" = "Temperatura mínima (\u00B0C)";
"battery.editor.alert.minimum_temperature.message" = "Introduce la temperatura mínima en grados Celsius (\u00B0C).";
"battery.editor.alert.maximum_temperature.title" = "Editar temperatura máxima";
"battery.editor.alert.maximum_temperature.placeholder" = "Temperatura máxima (\u00B0C)";
"battery.editor.alert.maximum_temperature.message" = "Introduce la temperatura máxima en grados Celsius (\u00B0C).";
"battery.editor.alert.cancel" = "Cancelar";
"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.battery.rv.name" = "Banco LiFePO4 de servicio";
"sample.battery.workshop.name" = "Batería de respaldo del banco de trabajo";
"sample.charger.shore.name" = "Cargador de costa";
"sample.charger.dcdc.name" = "Cargador DC-DC";
"sample.charger.workbench.name" = "Cargador de banco de trabajo";
"chargers.title" = "Cargadores para %@";
"chargers.subtitle" = "Los componentes de carga estarán disponibles pronto.";
"cable.pro.paywall.title" = "Cable PRO";
"cable.pro.paywall.subtitle" = "Cable PRO permite más opciones de configuración para cargas, baterías y cargadores.";
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
"generic.ok" = "Aceptar";

View File

@@ -14,6 +14,33 @@
"bom.navigation.title.system" = "Liste de matériel %@";
"bom.size.unknown" = "Taille à déterminer";
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
"bom.empty.message" = "Aucun composant enregistré pour ce système pour linstant.";
"bom.export.pdf.button" = "Exporter en PDF";
"bom.export.pdf.error.title" = "Échec de lexport";
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant lexport.";
"bom.pdf.header.title" = "Liste de matériaux du système";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Système dunités : %@";
"bom.pdf.placeholder.empty" = "Aucun composant disponible.";
"bom.pdf.page.number" = "Page %d";
"bom.category.components.title" = "Composants et chargeurs";
"bom.category.components.subtitle" = "Appareils principaux, contrôleurs et équipements de charge.";
"bom.category.batteries.title" = "Batteries";
"bom.category.batteries.subtitle" = "Banques domestiques et stockage.";
"bom.category.cables.title" = "Câbles";
"bom.category.cables.subtitle" = "Liaisons dimensionnées pour chaque circuit.";
"bom.category.fuses.title" = "Fusibles";
"bom.category.fuses.subtitle" = "Protection des circuits et porte-fusibles.";
"bom.category.accessories.title" = "Accessoires";
"bom.category.accessories.subtitle" = "Fusibles, cosses et pièces complémentaires.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"component.fallback.name" = "Composant";
"default.load.library" = "Charge de la bibliothèque";
"default.load.name" = "Ma charge";
@@ -33,6 +60,18 @@
"slider.length.title" = "Longueur du câble (%@)";
"slider.power.title" = "Puissance";
"slider.voltage.title" = "Tension";
"calculator.advanced.section.title" = "Paramètres avancés";
"calculator.advanced.duty_cycle.title" = "Facteur de marche";
"calculator.advanced.duty_cycle.helper" = "Pourcentage du temps actif pendant lequel la charge consomme réellement de l'énergie.";
"calculator.advanced.usage_hours.title" = "Temps de fonctionnement quotidien";
"calculator.advanced.usage_hours.helper" = "Heures par jour pendant lesquelles la charge est allumée.";
"calculator.advanced.usage_hours.unit" = "h/jour";
"calculator.alert.duty_cycle.title" = "Modifier le facteur de marche";
"calculator.alert.duty_cycle.placeholder" = "Facteur de marche";
"calculator.alert.duty_cycle.message" = "Saisissez le facteur de marche en pourcentage (0-100 %).";
"calculator.alert.usage_hours.title" = "Modifier le temps de fonctionnement quotidien";
"calculator.alert.usage_hours.placeholder" = "Temps de fonctionnement quotidien";
"calculator.alert.usage_hours.message" = "Saisissez le nombre d'heures par jour pendant lesquelles la charge est active.";
"system.list.no.components" = "Aucun composant pour l'instant";
"units.imperial.display" = "Impérial (AWG, ft)";
"units.metric.display" = "Métrique (mm², m)";
@@ -135,15 +174,68 @@
"VoltPlan Library" = "Bibliothèque VoltPlan";
"New Load" = "Nouvelle charge";
"tab.overview" = "Aperçu";
"tab.components" = "Composants";
"tab.batteries" = "Batteries";
"tab.chargers" = "Chargeurs";
"loads.overview.header.title" = "Aperçu des charges";
"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";
"loads.overview.status.missing_details.plural" = "charges";
"loads.overview.status.missing_details.banner" = "Terminez la configuration de vos charges";
"loads.metric.fuse" = "Fusible";
"loads.metric.cable" = "Câble";
"loads.metric.length" = "Longueur";
"overview.system.header.title" = "Aperçu du système";
"overview.loads.empty.title" = "Aucune charge configurée pour l'instant";
"overview.loads.empty.subtitle" = "Ajoutez des charges pour obtenir des recommandations de câbles et de fusibles adaptées à ce système.";
"overview.runtime.title" = "Autonomie estimée";
"overview.runtime.subtitle" = "Avec la charge actuelle";
"overview.runtime.unavailable" = "Ajoutez la capacité de la batterie et la puissance consommée pour estimer lautonomie.";
"overview.bom.title" = "Liste de matériel";
"overview.bom.subtitle" = "Touchez pour consulter les composants";
"overview.bom.unavailable" = "Ajoutez des charges pour générer des composants.";
"overview.bom.placeholder.short" = "Ajouter des charges";
"overview.chargetime.title" = "Temps de charge estimé";
"overview.chargetime.subtitle" = "Au débit de charge combiné";
"overview.chargetime.unavailable" = "Ajoutez des chargeurs et de la capacité batterie pour estimer.";
"overview.chargetime.placeholder.short" = "Ajouter des chargeurs";
"overview.goal.prefix" = "Objectif";
"overview.goal.label" = "Objectif %@";
"overview.goal.clear" = "Supprimer l'objectif";
"overview.goal.cancel" = "Annuler";
"overview.goal.save" = "Enregistrer";
"overview.runtime.goal.title" = "Objectif d'autonomie";
"overview.chargetime.goal.title" = "Objectif de recharge";
"overview.runtime.placeholder.short" = "Ajouter capacité";
"battery.bank.warning.voltage.short" = "Tension";
"battery.bank.warning.capacity.short" = "Capacité";
"battery.bank.header.title" = "Banque de batteries";
"battery.bank.metric.count" = "Batteries";
"battery.bank.metric.capacity" = "Capacité";
"battery.bank.metric.energy" = "Énergie";
"battery.bank.metric.usable_capacity" = "Capacité utilisable";
"battery.bank.metric.usable_energy" = "Énergie utilisable";
"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";
"overview.chargers.header.title" = "Vue densemble des chargeurs";
"overview.chargers.empty.title" = "Aucun chargeur configuré pour linstant";
"overview.chargers.empty.subtitle" = "Ajoutez des chargeurs secteur, DC-DC ou solaires pour comprendre votre capacité de charge.";
"overview.chargers.empty.create" = "Ajouter un chargeur";
"battery.bank.badge.capacity" = "Capacité";
"battery.bank.badge.energy" = "Énergie";
"battery.bank.banner.voltage" = "Écart de tension détecté";
@@ -167,15 +259,95 @@
"battery.editor.section.summary" = "Résumé";
"battery.editor.slider.voltage" = "Tension nominale";
"battery.editor.slider.capacity" = "Capacité";
"battery.editor.slider.usable_capacity" = "Capacité utilisable (%)";
"battery.editor.slider.charge_voltage" = "Tension de charge";
"battery.editor.slider.cutoff_voltage" = "Tension de coupure";
"battery.editor.slider.temperature_range" = "Plage de température";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.section.advanced" = "Avancé";
"battery.editor.button.reset_default" = "Réinitialiser";
"battery.editor.advanced.usable_capacity.footer_default" = "Valeur par défaut %@ selon la chimie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Remplacement actif. La valeur par défaut liée à la chimie reste %@.";
"battery.editor.advanced.charge_voltage.helper" = "Définissez la tension de charge maximale recommandée.";
"battery.editor.advanced.cutoff_voltage.helper" = "Définissez la tension minimale de décharge sûre.";
"battery.editor.advanced.temperature_range.helper" = "Définissez la plage de température de fonctionnement recommandée.";
"battery.editor.alert.voltage.title" = "Modifier la tension nominale";
"battery.editor.alert.voltage.placeholder" = "Tension";
"battery.editor.alert.voltage.message" = "Saisissez la tension en volts (V)";
"battery.editor.alert.capacity.title" = "Modifier la capacité";
"battery.editor.alert.capacity.placeholder" = "Capacité";
"battery.editor.alert.capacity.message" = "Saisissez la capacité en ampères-heures (Ah)";
"battery.editor.alert.usable_capacity.title" = "Modifier la capacité utilisable";
"battery.editor.alert.usable_capacity.placeholder" = "Capacité utilisable (%)";
"battery.editor.alert.usable_capacity.message" = "Saisissez le pourcentage de capacité utilisable (%)";
"battery.editor.alert.charge_voltage.title" = "Modifier la tension de charge";
"battery.editor.alert.charge_voltage.placeholder" = "Tension de charge";
"battery.editor.alert.charge_voltage.message" = "Saisissez la tension de charge en volts (V).";
"battery.editor.alert.cutoff_voltage.title" = "Modifier la tension de coupure";
"battery.editor.alert.cutoff_voltage.placeholder" = "Tension de coupure";
"battery.editor.alert.cutoff_voltage.message" = "Saisissez la tension de coupure en volts (V).";
"battery.editor.alert.minimum_temperature.title" = "Modifier la température minimale";
"battery.editor.alert.minimum_temperature.placeholder" = "Température minimale (\u00B0C)";
"battery.editor.alert.minimum_temperature.message" = "Saisissez la température minimale en degrés Celsius (\u00B0C).";
"battery.editor.alert.maximum_temperature.title" = "Modifier la température maximale";
"battery.editor.alert.maximum_temperature.placeholder" = "Température maximale (\u00B0C)";
"battery.editor.alert.maximum_temperature.message" = "Saisissez la température maximale en degrés Celsius (\u00B0C).";
"battery.editor.alert.cancel" = "Annuler";
"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.battery.rv.name" = "Batterie de service LiFePO4";
"sample.battery.workshop.name" = "Batterie de secours de l'établi";
"sample.charger.shore.name" = "Chargeur de quai";
"sample.charger.dcdc.name" = "Chargeur DC-DC";
"sample.charger.workbench.name" = "Chargeur d'établi";
"chargers.title" = "Chargeurs pour %@";
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
"cable.pro.paywall.title" = "Cable PRO";
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
"cable.pro.feature.usageBased" = "Calculs basés sur lutilisation";
"generic.ok" = "OK";

View File

@@ -14,6 +14,33 @@
"bom.navigation.title.system" = "Materiaallijst %@";
"bom.size.unknown" = "Afmeting nog onbekend";
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
"bom.export.pdf.button" = "PDF exporteren";
"bom.export.pdf.error.title" = "Export mislukt";
"bom.export.pdf.error.empty" = "Voeg minimaal één component toe voordat je exporteert.";
"bom.pdf.header.title" = "Stuklijst van het systeem";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Maateenheid: %@";
"bom.pdf.placeholder.empty" = "Geen componenten beschikbaar.";
"bom.pdf.page.number" = "Pagina %d";
"bom.category.components.title" = "Componenten en laders";
"bom.category.components.subtitle" = "Hoofdapparaten, regelaars en laadapparatuur.";
"bom.category.batteries.title" = "Batterijen";
"bom.category.batteries.subtitle" = "Huishoudbanken en opslag.";
"bom.category.cables.title" = "Kabels";
"bom.category.cables.subtitle" = "Op maat gemaakte stroomtrajecten per circuit.";
"bom.category.fuses.title" = "Zekeringen";
"bom.category.fuses.subtitle" = "Circuitbeveiliging en houders.";
"bom.category.accessories.title" = "Accessoires";
"bom.category.accessories.subtitle" = "Zekeringen, kabelschoenen en ondersteunende onderdelen.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"component.fallback.name" = "Component";
"default.load.library" = "Bibliotheeklast";
"default.load.name" = "Mijn last";
@@ -33,6 +60,18 @@
"slider.length.title" = "Kabellengte (%@)";
"slider.power.title" = "Vermogen";
"slider.voltage.title" = "Spanning";
"calculator.advanced.section.title" = "Geavanceerde instellingen";
"calculator.advanced.duty_cycle.title" = "Inschakelduur";
"calculator.advanced.duty_cycle.helper" = "Percentage van de actieve tijd waarin de belasting daadwerkelijk vermogen vraagt.";
"calculator.advanced.usage_hours.title" = "Dagelijkse aan-tijd";
"calculator.advanced.usage_hours.helper" = "Uren per dag dat de belasting is ingeschakeld.";
"calculator.advanced.usage_hours.unit" = "u/dag";
"calculator.alert.duty_cycle.title" = "Inschakelduur bewerken";
"calculator.alert.duty_cycle.placeholder" = "Inschakelduur";
"calculator.alert.duty_cycle.message" = "Voer de inschakelduur in als percentage (0-100%).";
"calculator.alert.usage_hours.title" = "Dagelijkse aan-tijd bewerken";
"calculator.alert.usage_hours.placeholder" = "Dagelijkse aan-tijd";
"calculator.alert.usage_hours.message" = "Voer het aantal uren per dag in dat de belasting actief is.";
"system.list.no.components" = "Nog geen componenten";
"units.imperial.display" = "Imperiaal (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
@@ -135,15 +174,68 @@
"VoltPlan Library" = "VoltPlan-bibliotheek";
"New Load" = "Nieuwe last";
"tab.overview" = "Overzicht";
"tab.components" = "Componenten";
"tab.batteries" = "Batterijen";
"tab.chargers" = "Laders";
"loads.overview.header.title" = "Lastenoverzicht";
"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";
"loads.overview.status.missing_details.plural" = "lasten";
"loads.overview.status.missing_details.banner" = "Rond de configuratie van je lasten af";
"loads.metric.fuse" = "Zekering";
"loads.metric.cable" = "Kabel";
"loads.metric.length" = "Lengte";
"overview.system.header.title" = "Systeemoverzicht";
"overview.loads.empty.title" = "Nog geen lasten geconfigureerd";
"overview.loads.empty.subtitle" = "Voeg lasten toe om kabel- en zekeringsadviezen te krijgen die zijn afgestemd op dit systeem.";
"overview.runtime.title" = "Geschatte looptijd";
"overview.runtime.subtitle" = "Bij huidige belasting";
"overview.runtime.unavailable" = "Voeg batterijcapaciteit en vermogensvraag toe om de looptijd te berekenen.";
"overview.bom.title" = "Stuklijst";
"overview.bom.subtitle" = "Tik om componenten te bekijken";
"overview.bom.unavailable" = "Voeg verbruikers toe om componenten te genereren.";
"overview.bom.placeholder.short" = "Lasten toevoegen";
"overview.chargetime.title" = "Geschatte laadtijd";
"overview.chargetime.subtitle" = "Met gecombineerde laadsnelheid";
"overview.chargetime.unavailable" = "Voeg laders en accucapaciteit toe voor een schatting.";
"overview.chargetime.placeholder.short" = "Laders toevoegen";
"overview.goal.prefix" = "Doel";
"overview.goal.label" = "Doel %@";
"overview.goal.clear" = "Doel verwijderen";
"overview.goal.cancel" = "Annuleren";
"overview.goal.save" = "Opslaan";
"overview.runtime.goal.title" = "Looptijddoel";
"overview.chargetime.goal.title" = "Laadtijddoel";
"overview.runtime.placeholder.short" = "Capaciteit toevoegen";
"battery.bank.warning.voltage.short" = "Spanning";
"battery.bank.warning.capacity.short" = "Capaciteit";
"battery.bank.header.title" = "Accubank";
"battery.bank.metric.count" = "Batterijen";
"battery.bank.metric.capacity" = "Capaciteit";
"battery.bank.metric.energy" = "Energie";
"battery.bank.metric.usable_capacity" = "Beschikbare capaciteit";
"battery.bank.metric.usable_energy" = "Beschikbare 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";
"overview.chargers.header.title" = "Overzicht van laders";
"overview.chargers.empty.title" = "Nog geen laders geconfigureerd";
"overview.chargers.empty.subtitle" = "Voeg walstroom-, DC-DC- of zonneladers toe om je laadvermogen te begrijpen.";
"overview.chargers.empty.create" = "Lader toevoegen";
"battery.bank.badge.capacity" = "Capaciteit";
"battery.bank.badge.energy" = "Energie";
"battery.bank.banner.voltage" = "Spanningsafwijking gedetecteerd";
@@ -167,15 +259,95 @@
"battery.editor.section.summary" = "Overzicht";
"battery.editor.slider.voltage" = "Nominale spanning";
"battery.editor.slider.capacity" = "Capaciteit";
"battery.editor.slider.usable_capacity" = "Beschikbare capaciteit (%)";
"battery.editor.slider.charge_voltage" = "Laadspanning";
"battery.editor.slider.cutoff_voltage" = "Afsluitspanning";
"battery.editor.slider.temperature_range" = "Temperatuurbereik";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.section.advanced" = "Geavanceerd";
"battery.editor.button.reset_default" = "Resetten";
"battery.editor.advanced.usable_capacity.footer_default" = "Standaardwaarde %@ op basis van de chemie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Handmatige override actief. Chemische standaard blijft %@.";
"battery.editor.advanced.charge_voltage.helper" = "Stel de maximaal aanbevolen laadspanning in.";
"battery.editor.advanced.cutoff_voltage.helper" = "Stel de minimale veilige ontlaadspanning in.";
"battery.editor.advanced.temperature_range.helper" = "Bepaal het aanbevolen temperatuurbereik voor gebruik.";
"battery.editor.alert.voltage.title" = "Nominale spanning bewerken";
"battery.editor.alert.voltage.placeholder" = "Spanning";
"battery.editor.alert.voltage.message" = "Voer de spanning in volt (V) in";
"battery.editor.alert.capacity.title" = "Capaciteit bewerken";
"battery.editor.alert.capacity.placeholder" = "Capaciteit";
"battery.editor.alert.capacity.message" = "Voer de capaciteit in ampère-uur (Ah) in";
"battery.editor.alert.usable_capacity.title" = "Beschikbare capaciteit bewerken";
"battery.editor.alert.usable_capacity.placeholder" = "Beschikbare capaciteit (%)";
"battery.editor.alert.usable_capacity.message" = "Voer het percentage beschikbare capaciteit (%) in";
"battery.editor.alert.charge_voltage.title" = "Laadspanning bewerken";
"battery.editor.alert.charge_voltage.placeholder" = "Laadspanning";
"battery.editor.alert.charge_voltage.message" = "Voer de laadspanning in volt (V) in.";
"battery.editor.alert.cutoff_voltage.title" = "Afsluitspanning bewerken";
"battery.editor.alert.cutoff_voltage.placeholder" = "Afsluitspanning";
"battery.editor.alert.cutoff_voltage.message" = "Voer de afsluitspanning in volt (V) in.";
"battery.editor.alert.minimum_temperature.title" = "Minimale temperatuur bewerken";
"battery.editor.alert.minimum_temperature.placeholder" = "Minimale temperatuur (\u00B0C)";
"battery.editor.alert.minimum_temperature.message" = "Voer de minimale temperatuur in graden Celsius (\u00B0C) in.";
"battery.editor.alert.maximum_temperature.title" = "Maximale temperatuur bewerken";
"battery.editor.alert.maximum_temperature.placeholder" = "Maximale temperatuur (\u00B0C)";
"battery.editor.alert.maximum_temperature.message" = "Voer de maximale temperatuur in graden Celsius (\u00B0C) in.";
"battery.editor.alert.cancel" = "Annuleren";
"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.battery.rv.name" = "LiFePO4-huishoudaccu";
"sample.battery.workshop.name" = "Reserveaccu voor werkbank";
"sample.charger.shore.name" = "Walstroomlader";
"sample.charger.dcdc.name" = "DC-DC-lader";
"sample.charger.workbench.name" = "Werkplaatslader";
"chargers.title" = "Laders voor %@";
"chargers.subtitle" = "Ladercomponenten zijn binnenkort beschikbaar.";
"cable.pro.paywall.title" = "Cable PRO";
"cable.pro.paywall.subtitle" = "Cable PRO biedt meer configuratie-opties voor verbruikers, batterijen en laders.";
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
"generic.ok" = "OK";

View File

@@ -11,40 +11,75 @@ import Testing
struct CableTests {
@Test func metricWireSizingUsesNearestStandardSize() async throws {
let calculator = CableCalculator()
calculator.voltage = 12
calculator.current = 5
calculator.length = 10 // meters
let crossSection = calculator.recommendedCrossSection(for: .metric)
let crossSection = ElectricalCalculations.recommendedCrossSection(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(crossSection == 4.0)
let voltageDrop = calculator.voltageDrop(for: .metric)
let voltageDrop = ElectricalCalculations.voltageDrop(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(voltageDrop - 0.425) < 0.001)
let dropPercentage = calculator.voltageDropPercentage(for: .metric)
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(dropPercentage - 3.5417) < 0.001)
let powerLoss = calculator.powerLoss(for: .metric)
let powerLoss = ElectricalCalculations.powerLoss(
length: 10,
current: 5,
voltage: 12,
unitSystem: .metric
)
#expect(abs(powerLoss - 2.125) < 0.001)
}
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
let calculator = CableCalculator()
calculator.voltage = 120
calculator.current = 15
calculator.length = 25 // feet
let awg = calculator.recommendedCrossSection(for: .imperial)
let awg = ElectricalCalculations.recommendedCrossSection(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(awg == 18.0)
let voltageDrop = calculator.voltageDrop(for: .imperial)
let voltageDrop = ElectricalCalculations.voltageDrop(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(abs(voltageDrop - 4.722) < 0.01)
let dropPercentage = calculator.voltageDropPercentage(for: .imperial)
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(abs(dropPercentage - 3.935) < 0.01)
let powerLoss = calculator.powerLoss(for: .imperial)
let powerLoss = ElectricalCalculations.powerLoss(
length: 25,
current: 15,
voltage: 120,
unitSystem: .imperial
)
#expect(abs(powerLoss - 70.83) < 0.05)
}
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10)
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80)
}
}

View File

@@ -1,3 +1,4 @@
import Foundation
import Testing
@testable import Cable
@@ -15,7 +16,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let german = Locale(identifier: "de_DE")
let german = Foundation.Locale(identifier: "de_DE")
#expect(item.localizedName(for: german) == "Ankerwinde")
}
@@ -31,7 +32,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let french = Locale(identifier: "fr_FR")
let french = Foundation.Locale(identifier: "fr_FR")
#expect(item.localizedName(for: french) == "Anchor Winch")
}
@@ -66,7 +67,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let spanishMexico = Locale(identifier: "es_MX")
let spanishMexico = Foundation.Locale(identifier: "es_MX")
#expect(item.localizedName(for: spanishMexico) == "Molinete")
}
@@ -82,7 +83,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let germanSwitzerland = Locale(identifier: "de_CH")
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
}
@@ -98,7 +99,7 @@ struct ComponentLibraryItemTests {
affiliateLinks: []
)
let french = Locale(identifier: "fr_FR")
let french = Foundation.Locale(identifier: "fr_FR")
#expect(item.localizedName(for: french) == "Guindeau")
}
}

View File

@@ -1,32 +1,536 @@
//
// CableUITestsScreenshot.swift
// CableUITestsScreenshot
//
// Created by Stefan Lange-Hegermann on 06.10.25.
//
import XCTest
final class CableUITestsScreenshot: XCTestCase {
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
private enum UIStringKey: String {
case addLoad
case browseLibrary
case library
case overviewTab
case componentsTab
case batteriesTab
case chargersTab
case close
case cancel
case settings
case defaultLoadName
case billOfMaterials
case systemEditorTitle
case systemsTitle
}
private let translations: [UIStringKey: [String: String]] = [
.addLoad: [
"en": "Add Load",
"de": "Verbraucher hinzufügen",
"es": "Añadir carga",
"fr": "Ajouter une charge",
"nl": "Belasting toevoegen",
],
.browseLibrary: [
"en": "Browse Library",
"de": "Bibliothek durchsuchen",
"es": "Explorar biblioteca",
"fr": "Parcourir la bibliothèque",
"nl": "Bibliotheek bekijken",
],
.library: [
"en": "Library",
"de": "Bibliothek",
"es": "Biblioteca",
"fr": "Bibliothèque",
"nl": "Bibliotheek",
],
.overviewTab: [
"en": "Overview",
"de": "Übersicht",
"es": "Resumen",
"fr": "Aperçu",
"nl": "Overzicht",
],
.componentsTab: [
"en": "Components",
"de": "Verbraucher",
"es": "Componentes",
"fr": "Composants",
"nl": "Componenten",
],
.batteriesTab: [
"en": "Batteries",
"de": "Batterien",
"es": "Baterías",
"fr": "Batteries",
"nl": "Batterijen",
],
.chargersTab: [
"en": "Chargers",
"de": "Ladegeräte",
"es": "Cargadores",
"fr": "Chargeurs",
"nl": "Laders",
],
.close: [
"en": "Close",
"de": "Schließen",
"es": "Cerrar",
"fr": "Fermer",
"nl": "Sluiten",
],
.cancel: [
"en": "Cancel",
"de": "Abbrechen",
"es": "Cancelar",
"fr": "Annuler",
"nl": "Annuleren",
],
.settings: [
"en": "Settings",
"de": "Einstellungen",
"es": "Configuración",
"fr": "Réglages",
"nl": "Instellingen",
],
.defaultLoadName: [
"en": "New Load",
"de": "Neuer Verbraucher",
"es": "Carga nueva",
"fr": "Nouvelle charge",
"nl": "Nieuwe last",
],
.billOfMaterials: [
"en": "Bill of Materials",
"de": "Stückliste",
"es": "Lista de materiales",
"fr": "Liste de matériel",
"nl": "Stuklijst",
],
.systemEditorTitle: [
"en": "Edit System",
"de": "System bearbeiten",
"es": "Editar sistema",
"fr": "Modifier le système",
"nl": "Systeem bewerken",
],
.systemsTitle: [
"en": "Systems",
"de": "Systeme",
"es": "Sistemas",
"fr": "Systèmes",
"nl": "Systemen",
],
]
override func setUpWithError() throws {
continueAfterFailure = false
try super.setUpWithError()
ensureDoNotDisturbEnabled()
dismissSystemOverlays()
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
dismissSystemOverlays()
//dismissSystemOverlays()
}
@MainActor
func testExample() throws {
func testOnboardingScreenshots() throws {
let app = launchApp(arguments: ["--uitest-reset-data"])
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
waitForStability(long: true)
dismissNotificationBannersIfNeeded()
waitForStability(long: true)
takeScreenshot(named: "01-OnboardingSystemsView")
createSystemButton.tap()
let addLoadButton = button(in: app.buttons, for: .addLoad)
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
waitForStability()
takeScreenshot(named: "02-OnboardingSystemView")
browseLibraryButton.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
waitForStability(long: true)
takeScreenshot(named: "04-ComponentSelectorView")
libraryCloseButton.tap()
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 4))
addLoadButton.tap()
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
waitForStability(long: true)
takeScreenshot(named: "03-LoadEditorView")
}
@MainActor
func testSampleDataScreenshots() throws {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
dismissSystemOverlays()
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label
let systemButton = firstSystemCell.buttons.firstMatch
if systemButton.exists {
systemButton.tap()
} else {
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
var detailVisible = waitForSystemDetail(named: systemName, in: app)
if !detailVisible {
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
detailVisible = waitForSystemDetail(named: systemName, in: app)
}
XCTAssertTrue(detailVisible)
takeScreenshot(named: "06-AdventureVanOverview")
// let overviewTab = app.buttons["overview-tab"]
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
// overviewTab.tap()
waitForStability(long: false)
let bomElement = resolveBillOfMaterialsElement(in: app)
if !bomElement.waitForExistence(timeout: 6) {
bringElementIntoView(bomElement, in: app)
}
XCTAssertTrue(bomElement.exists)
if !bomElement.isHittable {
bringElementIntoView(bomElement, in: app, requireHittable: true)
}
if bomElement.isHittable {
bomElement.tap()
} else {
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
waitForStability(long: true)
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
let componentsTab = componentsTabButton(in: app)
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
if componentsTab.isHittable {
componentsTab.tap()
} else {
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
takeScreenshot(named: "07-AdventureVanLoads")
waitForStability()
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
let loadName = firstLoad.staticTexts.firstMatch.label
firstLoad.tap()
let loadNavButton = app.navigationBars.buttons[loadName]
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
takeScreenshot(named: "09-AdventureVanCalculator")
}
private func launchApp(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
//dismissSystemOverlays()
return app
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
let table = app.tables["systems-list"]
if table.waitForExistence(timeout: 6) {
return table
}
XCTAssertTrue(app.tables.firstMatch.waitForExistence(timeout: 2))
return app.tables.firstMatch
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let identifierMatch = app.descendants(matching: .any)
.matching(identifier: "components-tab").firstMatch
if identifierMatch.exists {
return identifierMatch
}
let localizedLabels = [
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
]
for label in localizedLabels {
let button = app.buttons[label]
if button.exists {
return button
}
let tabBarButton = app.tabBars.buttons[label]
if tabBarButton.exists {
return tabBarButton
}
let segmentedButton = app.segmentedControls.buttons[label]
if segmentedButton.exists {
return segmentedButton
}
let segmentedOther = app.segmentedControls.otherElements[label]
if segmentedOther.exists {
return segmentedOther
}
}
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
if fallbackSegmented.exists {
return fallbackSegmented
}
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
if tabBarButton.exists {
return tabBarButton
}
return app.tabBars.descendants(matching: .any).firstMatch
}
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.otherElements["system-overview"].exists {
return true
}
let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
}
return app.otherElements["system-overview"].exists
}
private func bringElementIntoView(
_ element: XCUIElement,
in app: XCUIApplication,
requireHittable: Bool = false,
attempts: Int = 8
) {
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.tables.firstMatch
for _ in 0..<attempts {
if element.exists, (!requireHittable || element.isHittable) {
return
}
if scrollContainer.exists {
scrollContainer.swipeUp()
} else {
app.swipeUp()
}
waitForStability()
_ = element.waitForExistence(timeout: 2)
}
}
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
let identifier = "system-bom-button"
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
if buttonByIdentifier.exists { return buttonByIdentifier }
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
if elementByIdentifier.exists { return elementByIdentifier }
let candidates = candidateStrings(for: .billOfMaterials)
for candidate in candidates {
let button = app.buttons[candidate]
if button.exists {
return button
}
let other = app.otherElements[candidate]
if other.exists {
return other
}
}
return buttonByIdentifier
}
private func dismissNotificationBannersIfNeeded() {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
if banner.waitForExistence(timeout: 1) {
if banner.isHittable {
banner.swipeUp()
} else {
let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -1.5))
start.press(forDuration: 0.05, thenDragTo: end)
}
waitForStability()
}
}
private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
let localized = translations[key]?[languageCode] {
values.insert(localized)
}
if let english = translations[key]?["en"] {
values.insert(english)
}
if let others = translations[key]?.values {
values.formUnion(others)
}
if key == .settings {
values.insert("gearshape")
}
return Array(values)
}
private func button(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let element = query[candidate]
if element.exists {
return element
}
}
let predicate = NSPredicate(
format: "label IN %@ OR identifier IN %@",
NSArray(array: candidates),
NSArray(array: candidates)
)
return query.matching(predicate).firstMatch
}
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
let element = button(in: query, for: key)
return element.exists ? element : nil
}
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let tabSpecific = button(in: app.tabBars.buttons, for: key)
if tabSpecific.exists {
return tabSpecific
}
return button(in: app.buttons, for: key)
}
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let bar = app.navigationBars[candidate]
if bar.exists {
return bar
}
}
return app.navigationBars.element(boundBy: 0)
}
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let button = app.buttons[candidate]
if button.waitForExistence(timeout: 2) {
button.tap()
return
}
}
}
private func openBillOfMaterials(app: XCUIApplication) {
let bomButton = button(in: app.buttons, for: .billOfMaterials)
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
waitForStability(long: true)
}
private func closeBillOfMaterials(app: XCUIApplication) {
tapButtonIfPresent(app: app, key: .close)
}
private func navigateBack(app: XCUIApplication) {
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists {
backButton.tap()
} else {
app.swipeRight()
}
}
private func openSettings(app: XCUIApplication) {
let systemsBar = navigationBar(in: app, key: .systemsTitle)
let settingsButton = button(in: systemsBar.buttons, for: .settings)
if settingsButton.exists {
settingsButton.tap()
} else {
systemsBar.buttons.element(boundBy: 0).tap()
}
}
private func ensureDoNotDisturbEnabled() {
@@ -41,6 +545,8 @@ final class CableUITestsScreenshot: XCTestCase {
focusTile.press(forDuration: 1.0)
} else if focusButton.waitForExistence(timeout: 2) {
focusButton.press(forDuration: 1.0)
} else {
return
}
let dndButton = springboard.buttons["Do Not Disturb"]

View File

@@ -8,6 +8,36 @@
import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase {
private func launchApp(arguments: [String] = []) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
return app
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["systems-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(name: String,
@@ -30,9 +60,7 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
@MainActor
func testOnboardingLoadsView() throws {
let app = XCUIApplication()
app.launch()
let app = launchApp(arguments: ["--uitest-reset-data"])
takeScreenshot(name: "01-OnboardingSystemsView")
let createSystemButton = app.buttons["create-system-button"]
@@ -40,56 +68,55 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
createSystemButton.tap()
takeScreenshot(name: "02-OnboardingLoadsView")
let componentsTab = app.buttons["components-tab"]
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
componentsTab.tap()
let libraryCloseButton = app.buttons["library-view-close-button"]
let selectComponentButton = app.buttons["select-component-button"]
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
selectComponentButton.tap()
let browseLibraryButton = onboardingSecondaryButton(in: app)
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
browseLibraryButton.tap()
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 10)
takeScreenshot(name: "04-ComponentSelectorView")
libraryCloseButton.tap()
let createComponentButton = app.buttons["create-component-button"]
let createComponentButton = onboardingPrimaryButton(in: app)
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
createComponentButton.tap()
takeScreenshot(name: "03-LoadEditorView")
}
func testWithSampleData() throws {
let app = XCUIApplication()
app.launchArguments.append("--uitest-sample-data")
app.launch()
let app = launchApp(arguments: ["--uitest-sample-data"])
let systemsCollection = app.collectionViews.firstMatch
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
let systemsList: XCUIElement
if collectionExists {
systemsList = systemsCollection
} else {
let table = app.tables.firstMatch
XCTAssertTrue(table.waitForExistence(timeout: 3))
systemsList = table
}
let systemsList = resolvedSystemsList(in: app)
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
let systemName = firstSystemCell.staticTexts.firstMatch.label
takeScreenshot(name: "05-SystemsWithSampleData")
firstSystemCell.tap()
let loadsCollection = app.collectionViews["loads-list"]
let loadsTable = app.tables["loads-list"]
let loadsElement: XCUIElement
if loadsCollection.waitForExistence(timeout: 3) {
loadsElement = loadsCollection
let rowButton = firstSystemCell.buttons.firstMatch
if rowButton.waitForExistence(timeout: 2) {
rowButton.tap()
} else {
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
loadsElement = loadsTable
firstSystemCell.tap()
}
let navButton = app.navigationBars.buttons[systemName]
if !navButton.waitForExistence(timeout: 3) {
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
coordinate.tap()
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
}
tapComponentsTab(in: app)
let loadsElement = resolvedLoadsList(in: app)
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "06-AdventureVanLoads")
@@ -98,9 +125,42 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
// let bomView = app.otherElements["system-bom-view"]
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
//
// Thread.sleep(forTimeInterval: 1)
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
}
private func tapComponentsTab(in app: XCUIApplication) {
let button = componentsTabButton(in: app)
XCTAssertTrue(button.waitForExistence(timeout: 3))
button.tap()
}
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["create-component-button"]
if button.exists { return button }
return app.buttons["onboarding-primary-button"]
}
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
let button = app.buttons["select-component-button"]
if button.exists { return button }
return app.buttons["onboarding-secondary-button"]
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let idButton = app.buttons["components-tab"]
if idButton.exists {
return idButton
}
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
for label in labels {
let button = app.buttons[label]
if button.exists { return button }
}
return app.tabBars.buttons.element(boundBy: 1)
}
}

22
Podfile Normal file
View File

@@ -0,0 +1,22 @@
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'
platform :ios, '17.6'
target 'Cable' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
# Pods for Cable
target 'CableTests' do
inherit! :search_paths
# Pods for testing
end
target 'CableUITests' do
# Pods for testing
end
target 'CableUITestsScreenshot' do
# Pods for testing
end
end

View File

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

View File

@@ -15,8 +15,8 @@ is_truthy() {
}
DEVICE_MATRIX=(
# "iPhone 17 Pro Max|26.0|iphone-17-pro-max"
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
)
command -v xcparse >/dev/null 2>&1 || {
@@ -83,6 +83,7 @@ for device_entry in "${DEVICE_MATRIX[@]}"; do
--batteryState charged --batteryLevel 100 \
--wifiBars 3
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"