Compare commits

...

42 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
Stefan Lange-Hegermann
4827ea4cdb localization updates 2025-10-21 10:43:51 +02:00
Stefan Lange-Hegermann
28ad6dd10c battery persistence 2025-10-21 09:55:43 +02:00
Stefan Lange-Hegermann
3c366dc454 adds templates for screenshots 2025-10-20 15:37:06 +02:00
Stefan Lange-Hegermann
420a6ea014 better presentation fot the App Store 2025-10-20 15:35:29 +02:00
Stefan Lange-Hegermann
dd13178f0e automated screenshot generation 2025-10-13 09:38:22 +02:00
Stefan Lange-Hegermann
cfcaab149f new version number 2025-10-09 10:39:23 +02:00
Stefan Lange-Hegermann
5d7c886ee8 adds a readme 2025-10-07 22:45:02 +02:00
Stefan Lange-Hegermann
296cf63176 automated screenshots with fastlane 2025-10-07 22:38:11 +02:00
Stefan Lange-Hegermann
16fd491af5 french and nederlands 2025-10-04 19:40:34 +02:00
Stefan Lange-Hegermann
7c5c4dff5c more translations 2025-10-04 19:14:46 +02:00
Stefan Lange-Hegermann
cb628277fb improved translation 2025-10-03 00:21:44 +02:00
Stefan Lange-Hegermann
03aa843f26 german and spanish translation 2025-10-03 00:15:52 +02:00
Stefan Lange-Hegermann
2f0cebceed better app icon 2025-10-01 11:25:34 +02:00
Stefan Lange-Hegermann
ab5e3e14ac systems onboarding is done for now 2025-09-30 20:08:49 +02:00
Stefan Lange-Hegermann
a35ad49a58 dark mode images for onboarding 2025-09-30 19:55:43 +02:00
Stefan Lange-Hegermann
0842815133 graphically pleasing onboarding 2025-09-29 08:58:03 +02:00
Stefan Lange-Hegermann
5fb8997ab9 cleaner with less clutter 2025-09-27 18:35:29 +02:00
114 changed files with 17554 additions and 2658 deletions

2
.bundle/config Normal file
View File

@@ -0,0 +1,2 @@
---
BUNDLE_PATH: "vendor/bundle"

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.DS_*
fastlane/screenshots
xcshareddata
Vendor
Shots/Framed
Shots/Screenshots
*.xcresult

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
3.2.4

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

View File

@@ -1,39 +0,0 @@
{
"fill" : {
"linear-gradient" : [
"srgb:0.66422,0.66424,0.66423,1.00000",
"extended-gray:1.00000,1.00000"
]
},
"groups" : [
{
"layers" : [
{
"image-name" : "cablebyvoltplan-logomark copy.png",
"name" : "cablebyvoltplan-logomark copy",
"position" : {
"scale" : 1,
"translation-in-points" : [
-483.8671875,
391.375
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -6,11 +6,14 @@
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */ = {isa = PBXBuildFile; fileRef = 3E4BC9B72E7F5E9E0052324A /* Cable.icon */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 3E5C0BC42E72C0FD00247EC8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 3E5C0BCB2E72C0FD00247EC8;
remoteInfo = Cable;
};
3E5C0BDE2E72C0FE00247EC8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 3E5C0BC42E72C0FD00247EC8 /* Project object */;
@@ -28,7 +31,7 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
3E4BC9B72E7F5E9E0052324A /* Cable.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Cable.icon; sourceTree = "<group>"; };
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITestsScreenshot.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -42,9 +45,24 @@
);
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>";
};
3E5C0BCE2E72C0FD00247EC8 /* Cable */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -66,6 +84,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
3E37F6522E93FB6F00836187 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3E5C0BC92E72C0FD00247EC8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -96,8 +121,9 @@
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
3E5C0BCD2E72C0FD00247EC8 /* Products */,
3E4BC9B72E7F5E9E0052324A /* Cable.icon */,
57738E9B07763CFA62681EEE /* Pods */,
);
sourceTree = "<group>";
};
@@ -107,13 +133,42 @@
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */,
3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */,
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */,
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */,
);
name = Products;
sourceTree = "<group>";
};
57738E9B07763CFA62681EEE /* Pods */ = {
isa = PBXGroup;
children = (
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */;
buildPhases = (
3E37F6512E93FB6F00836187 /* Sources */,
3E37F6522E93FB6F00836187 /* Frameworks */,
3E37F6532E93FB6F00836187 /* Resources */,
);
buildRules = (
);
dependencies = (
3E37F65C2E93FB6F00836187 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
);
name = CableUITestsScreenshot;
productName = CableUITestsScreenshot;
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
3E5C0BCB2E72C0FD00247EC8 /* Cable */ = {
isa = PBXNativeTarget;
buildConfigurationList = 3E5C0BF02E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "Cable" */;
@@ -130,8 +185,6 @@
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
);
name = Cable;
packageProductDependencies = (
);
productName = Cable;
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
productType = "com.apple.product-type.application";
@@ -153,8 +206,6 @@
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
);
name = CableTests;
packageProductDependencies = (
);
productName = CableTests;
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
@@ -176,8 +227,6 @@
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
);
name = CableUITests;
packageProductDependencies = (
);
productName = CableUITests;
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -189,9 +238,13 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1640;
LastUpgradeCheck = 1640;
LastSwiftUpdateCheck = 2600;
LastUpgradeCheck = 2600;
TargetAttributes = {
3E37F6542E93FB6F00836187 = {
CreatedOnToolsVersion = 26.0.1;
TestTargetID = 3E5C0BCB2E72C0FD00247EC8;
};
3E5C0BCB2E72C0FD00247EC8 = {
CreatedOnToolsVersion = 16.4;
};
@@ -211,6 +264,10 @@
knownRegions = (
en,
Base,
de,
es,
fr,
nl,
);
mainGroup = 3E5C0BC32E72C0FD00247EC8;
minimizedProjectReferenceProxies = 1;
@@ -222,16 +279,23 @@
3E5C0BCB2E72C0FD00247EC8 /* Cable */,
3E5C0BDC2E72C0FE00247EC8 /* CableTests */,
3E5C0BE62E72C0FE00247EC8 /* CableUITests */,
3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
3E37F6532E93FB6F00836187 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3E5C0BCA2E72C0FD00247EC8 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -252,6 +316,13 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
3E37F6512E93FB6F00836187 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
3E5C0BC82E72C0FD00247EC8 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -276,6 +347,11 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
3E37F65C2E93FB6F00836187 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
targetProxy = 3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */;
};
3E5C0BDF2E72C0FE00247EC8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
@@ -289,16 +365,62 @@
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
3E37F65E2E93FB6F00836187 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = RE4FXQ754N;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.yuzuhub.CableUITestsScreenshot;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Cable;
};
name = Debug;
};
3E37F65F2E93FB6F00836187 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = RE4FXQ754N;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.yuzuhub.CableUITestsScreenshot;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Cable;
};
name = Release;
};
3E5C0BF12E72C0FE00247EC8 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
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;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -311,9 +433,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -326,10 +449,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
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;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
@@ -342,9 +469,10 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -404,12 +532,13 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -462,11 +591,12 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
};
@@ -547,6 +677,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */ = {
isa = XCConfigurationList;
buildConfigurations = (
3E37F65E2E93FB6F00836187 /* Debug */,
3E37F65F2E93FB6F00836187 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
3E5C0BC72E72C0FD00247EC8 /* Build configuration list for PBXProject "Cable" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -9,6 +9,19 @@
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>CableScreenshots.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>3E5C0BCB2E72C0FD00247EC8</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,295 @@
{
"fill" : {
"automatic-gradient" : "extended-gray:1.00000,1.00000"
},
"groups" : [
{
"blur-material" : null,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.31812,0.56494,0.59766,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"image-name" : "fuse-top.png",
"name" : "fuse-top",
"opacity" : 1,
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-225.390625
]
}
},
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.31812,0.56494,0.59766,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "body 3.png",
"name" : "body 3",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-74.9921875
]
}
}
],
"lighting" : "individual",
"name" : "Group",
"opacity" : 0.8,
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.8
}
},
{
"hidden" : false,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
}
],
"glass-specializations" : [
{
"appearance" : "tinted",
"value" : true
}
],
"image-name" : "legs 2.png",
"name" : "legs 2",
"opacity-specializations" : [
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
100
]
}
}
],
"lighting" : "combined",
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
},
{
"blur-material" : null,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"image-name" : "fuse-top.png",
"name" : "fuse-top",
"opacity" : 1,
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-225.390625
]
}
},
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:1.00000,0.57811,0.00000,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "body 3.png",
"name" : "body 3",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-74.9921875
]
}
}
],
"lighting" : "individual",
"name" : "Group",
"opacity" : 0.8,
"position" : {
"scale" : 1,
"translation-in-points" : [
65.078125,
-49.375
]
},
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.8
}
},
{
"hidden" : false,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
}
],
"glass-specializations" : [
{
"appearance" : "tinted",
"value" : true
}
],
"image-name" : "legs 2.png",
"name" : "legs 2",
"opacity-specializations" : [
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
-14.34375,
117.640625
]
}
}
],
"lighting" : "combined",
"position" : {
"scale" : 1,
"translation-in-points" : [
73.96875,
-62.640625
]
},
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,181 @@
{
"fill" : {
"linear-gradient" : [
"display-p3:0.94971,1.00000,0.96298,1.00000",
"extended-gray:1.00000,1.00000"
]
},
"groups" : [
{
"blur-material" : 0.9,
"layers" : [
{
"fill" : {
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
},
"glass" : true,
"hidden" : true,
"image-name" : "flash.png",
"name" : "flash",
"opacity" : 1,
"position" : {
"scale" : 0.8,
"translation-in-points" : [
7.4921875,
-218.84375
]
}
}
],
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : false,
"value" : 0.5
}
},
{
"blur-material" : null,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.31765,0.56471,0.59608,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
}
}
],
"image-name" : "fuse-top.png",
"name" : "fuse-top",
"opacity" : 1,
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-225.390625
]
}
},
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.31812,0.56494,0.59766,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
}
}
],
"glass" : true,
"hidden" : false,
"image-name" : "body 3.png",
"name" : "body 3",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
-74.9921875
]
}
}
],
"lighting" : "individual",
"name" : "Group",
"opacity" : 0.8,
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.8
}
},
{
"hidden" : false,
"layers" : [
{
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
}
}
],
"glass-specializations" : [
{
"appearance" : "tinted",
"value" : true
}
],
"image-name" : "legs 2.png",
"name" : "legs 2",
"opacity-specializations" : [
{
"appearance" : "tinted",
"value" : 1
}
],
"position" : {
"scale" : 0.9,
"translation-in-points" : [
0,
100
]
}
}
],
"lighting" : "combined",
"shadow" : {
"kind" : "layer-color",
"opacity" : 0.5
},
"specular" : false,
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "Icon1024_opaque.png",
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -5,11 +5,42 @@
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "voltplan-logo-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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

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

@@ -0,0 +1,52 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "boat-light.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "boat-dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View File

@@ -0,0 +1,52 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "cabin-light.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "cabin-dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -0,0 +1,52 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "charger-light.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "charger-dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -0,0 +1,52 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "coffee-light.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "coffee-dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

@@ -0,0 +1,52 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "router-light.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "router-dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -0,0 +1,52 @@
{
"images" : [
{
"idiom" : "universal",
"scale" : "1x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "bus-light.png",
"idiom" : "universal",
"scale" : "3x"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "bus-dark.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,326 @@
"affiliate.button.review_parts" = "Review parts";
"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";
"bom.item.cable.black" = "Power Cable (Black)";
"bom.item.cable.red" = "Power Cable (Red)";
"bom.item.fuse" = "Fuse & Holder";
"bom.item.terminals" = "Cable Shoes / Terminals";
"bom.navigation.title" = "Bill of Materials";
"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.new" = "New Load";
"default.load.unnamed" = "Unnamed Load";
"default.system.name" = "My System";
"default.system.new" = "New System";
"editor.load.name_field" = "Load name";
"editor.load.preview" = "Preview";
"editor.load.title" = "Edit Load";
"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.icon.keywords.battery" = "battery, storage";
"system.icon.keywords.boat" = "boat, marine, yacht, sail";
"system.icon.keywords.bolt" = "bolt, power, electric";
"system.icon.keywords.building" = "building, office, warehouse, factory, facility";
"system.icon.keywords.climate" = "climate, hvac, temperature";
"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";
"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

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d component</string>
<key>other</key>
<string>%d components</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,639 @@
import SwiftUI
struct BatteriesView: View {
@Binding var editMode: EditMode
let system: ElectricalSystem
let batteries: [SavedBattery]
let onEdit: (SavedBattery) -> Void
let onDelete: (IndexSet) -> Void
private enum BankStatus: Identifiable {
case voltage(target: Double, mismatchedCount: Int)
case capacity(target: Double, mismatchedCount: Int)
var id: String {
switch self {
case .voltage: return "voltage"
case .capacity: return "capacity"
}
}
var symbol: String {
switch self {
case .voltage: return "exclamationmark.triangle.fill"
case .capacity: return "exclamationmark.circle.fill"
}
}
var tint: Color {
switch self {
case .voltage: return .red
case .capacity: return .orange
}
}
var bannerText: String {
switch self {
case .voltage:
return String(
localized: "battery.bank.banner.voltage",
bundle: .main,
comment: "Short banner text describing a voltage mismatch"
)
case .capacity:
return String(
localized: "battery.bank.banner.capacity",
bundle: .main,
comment: "Short banner text describing a capacity mismatch"
)
}
}
}
private let voltageTolerance: Double = 0.05
private let capacityTolerance: Double = 0.5
@State private var activeStatus: BankStatus?
private var bankTitle: String {
String(
localized: "battery.bank.header.title",
bundle: .main,
comment: "Title for the battery bank summary section"
)
}
private var metricCountLabel: String {
String(
localized: "battery.bank.metric.count",
bundle: .main,
comment: "Label for number of batteries metric"
)
}
private var metricCapacityLabel: String {
String(
localized: "battery.bank.metric.capacity",
bundle: .main,
comment: "Label for total capacity metric"
)
}
private var metricEnergyLabel: String {
String(
localized: "battery.bank.metric.energy",
bundle: .main,
comment: "Label for total energy metric"
)
}
private var 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",
bundle: .main,
comment: "Label for voltage badge"
)
}
private var badgeCapacityLabel: String {
String(
localized: "battery.bank.badge.capacity",
bundle: .main,
comment: "Label for capacity badge"
)
}
private var badgeEnergyLabel: String {
String(
localized: "battery.bank.badge.energy",
bundle: .main,
comment: "Label for energy badge"
)
}
private var 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",
bundle: .main,
comment: "Title shown when no batteries are configured"
)
}
init(
system: ElectricalSystem,
batteries: [SavedBattery],
editMode: Binding<EditMode> = .constant(.inactive),
onEdit: @escaping (SavedBattery) -> Void = { _ in },
onDelete: @escaping (IndexSet) -> Void = { _ in }
) {
self.system = system
self.batteries = batteries
self.onEdit = onEdit
self.onDelete = onDelete
self._editMode = editMode
}
var body: some View {
VStack(spacing: 0) {
if batteries.isEmpty {
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
batteriesListWithHeader
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.alert(item: $activeStatus) { status in
let detail = detailInfo(for: status)
return Alert(
title: Text(detail.title),
message: Text(detail.message),
dismissButton: .default(
Text(
NSLocalizedString(
"battery.bank.status.dismiss",
bundle: .main,
value: "Got it",
comment: "Dismiss button title for battery bank status alert"
)
)
)
)
}
}
private var 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()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
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 {
activeStatus = status
} label: {
statusBanner(for: status)
}
.buttonStyle(.plain)
}
}
}
}
@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(for: battery)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(battery.name)
.font(.body.weight(.medium))
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Text(formattedValue(battery.nominalVoltage, unit: "V"))
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text(battery.chemistry.displayName)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule(style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
batteryMetricsScroll(for: battery)
}
.padding(.vertical, 16)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.systemBackground))
)
}
private func batteryIcon(for battery: SavedBattery) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.componentColor(named: battery.colorName))
.frame(width: 48, height: 48)
Image(systemName: battery.iconName.isEmpty ? "battery.100.bolt" : battery.iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
}
}
private var totalCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.capacityAmpHours
}
}
private var totalEnergy: Double {
batteries.reduce(0) { result, battery in
result + battery.energyWattHours
}
}
private var totalUsableCapacity: Double {
batteries.reduce(0) { result, battery in
result + battery.usableCapacityAmpHours
}
}
private var totalUsableCapacityShare: Double {
guard totalCapacity > 0 else { return 0 }
return max(0, min(1, totalUsableCapacity / totalCapacity))
}
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
)
}
private func statusBanner(for status: BankStatus) -> some View {
HStack(spacing: 10) {
Image(systemName: status.symbol)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(status.tint)
Text(status.bannerText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(status.tint)
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(status.tint.opacity(0.12))
)
}
private var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: "battery.100")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text(emptyTitle)
.font(.title3)
.fontWeight(.semibold)
Text(emptySubtitle(for: system.name))
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
private static let numberFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}()
private func formattedValue(_ value: Double, unit: String) -> String {
let numberString = Self.numberFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) \(unit)"
}
private 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(
from: batteries.map { $0.nominalVoltage },
scale: 0.1
)
}
private var dominantCapacity: Double? {
guard batteries.count > 1 else { return nil }
return dominantValue(
from: batteries.map { $0.capacityAmpHours },
scale: 1.0
)
}
private func dominantValue(from values: [Double], scale: Double) -> Double? {
guard !values.isEmpty else { return nil }
var counts: [Double: Int] = [:]
var bestKey: Double?
var bestCount = 0
for value in values {
let key = (value / scale).rounded() * scale
let newCount = (counts[key] ?? 0) + 1
counts[key] = newCount
if newCount > bestCount {
bestCount = newCount
bestKey = key
}
}
return bestKey
}
private var bankStatus: BankStatus? {
guard batteries.count > 1 else { return nil }
if let targetVoltage = dominantVoltage {
let mismatched = batteries.filter { abs($0.nominalVoltage - targetVoltage) > voltageTolerance }
if !mismatched.isEmpty {
return .voltage(target: targetVoltage, mismatchedCount: mismatched.count)
}
}
if let targetCapacity = dominantCapacity {
let mismatched = batteries.filter { abs($0.capacityAmpHours - targetCapacity) > capacityTolerance }
if !mismatched.isEmpty {
return .capacity(target: targetCapacity, mismatchedCount: mismatched.count)
}
}
return nil
}
private func emptySubtitle(for systemName: String) -> String {
let format = NSLocalizedString(
"battery.bank.empty.subtitle",
tableName: nil,
bundle: .main,
value: "Tap the plus button to configure a battery for %@.",
comment: "Subtitle shown when no batteries are configured"
)
return String(format: format, systemName)
}
private func detailInfo(for status: BankStatus) -> (title: String, message: String) {
switch status {
case let .voltage(target, mismatchedCount):
let countText = mismatchedCount == 1
? NSLocalizedString(
"battery.bank.status.single.battery",
bundle: .main,
value: "One battery",
comment: "Singular form describing mismatched battery count"
)
: String(
format: NSLocalizedString(
"battery.bank.status.multiple.batteries",
bundle: .main,
value: "%d batteries",
comment: "Plural form describing mismatched battery count"
),
mismatchedCount
)
let expected = formattedValue(target, unit: "V")
let message = String(
format: NSLocalizedString(
"battery.bank.status.voltage.message",
bundle: .main,
value: "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.",
comment: "Explanation for voltage mismatch in battery bank"
),
countText,
expected
)
return (
NSLocalizedString(
"battery.bank.status.voltage.title",
bundle: .main,
value: "Voltage mismatch",
comment: "Title for voltage mismatch alert"
),
message
)
case let .capacity(target, mismatchedCount):
let countText = mismatchedCount == 1
? NSLocalizedString(
"battery.bank.status.single.battery",
bundle: .main,
value: "One battery",
comment: "Singular form describing mismatched battery count"
)
: String(
format: NSLocalizedString(
"battery.bank.status.multiple.batteries",
bundle: .main,
value: "%d batteries",
comment: "Plural form describing mismatched battery count"
),
mismatchedCount
)
let expected = formattedValue(target, unit: "Ah")
let message = String(
format: NSLocalizedString(
"battery.bank.status.capacity.message",
bundle: .main,
value: "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.",
comment: "Explanation for capacity mismatch in battery bank"
),
countText,
expected
)
return (
NSLocalizedString(
"battery.bank.status.capacity.title",
bundle: .main,
value: "Capacity mismatch",
comment: "Title for capacity mismatch alert"
),
message
)
}
}
}
private enum BatteriesViewPreviewData {
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "green")
static let batteries: [SavedBattery] = [
SavedBattery(
name: "House Bank",
nominalVoltage: 12.8,
capacityAmpHours: 200,
chemistry: .lithiumIronPhosphate,
iconName: "battery.100.bolt",
colorName: "green",
system: system
),
SavedBattery(
name: "Starter Battery",
nominalVoltage: 12.0,
capacityAmpHours: 90,
chemistry: .agm,
iconName: "bolt",
colorName: "orange",
system: system
)
]
}
#Preview {
BatteriesView(
system: BatteriesViewPreviewData.system,
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

@@ -8,9 +8,5 @@
<array/>
<key>com.apple.developer.icloud-services</key>
<array/>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

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, 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, 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 {
@@ -30,10 +32,20 @@ struct CableApp: App {
}
}()
init() {
let unitSettings = UnitSystemSettings()
_unitSettings = StateObject(wrappedValue: unitSettings)
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
#if DEBUG
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
#endif
}
var body: some Scene {
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 = "My 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
}
}

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,76 +0,0 @@
import Foundation
import SwiftUI
import UIKit
struct LoadIconView: View {
let remoteIconURLString: String?
let fallbackSystemName: String
let fallbackColor: Color
let size: CGFloat
private var cornerRadius: CGFloat {
max(6, size / 4)
}
@State private var cachedImage: Image?
@State private var isLoading = false
@State private var hasAttemptedLoad = false
var body: some View {
Group {
if let cachedImage {
cachedImage
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
} else if let url = remoteURL, !hasAttemptedLoad {
ProgressView()
.frame(width: size, height: size)
.task(id: url) {
await loadImage(from: url)
}
} else {
fallbackView
}
}
.frame(width: size, height: size)
.onChange(of: remoteIconURLString) { _ in
cachedImage = nil
hasAttemptedLoad = false
}
}
private var fallbackView: some View {
ZStack {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(fallbackColor)
Image(systemName: fallbackSystemName.isEmpty ? "lightbulb" : fallbackSystemName)
.font(.system(size: size * 0.5))
.foregroundColor(.white)
}
}
private var remoteURL: URL? {
guard let remoteIconURLString, let url = URL(string: remoteIconURLString) else { return nil }
return url
}
private func loadImage(from url: URL) async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
if let uiImage = try? await IconCache.shared.image(for: url) {
await MainActor.run {
cachedImage = Image(uiImage: uiImage)
hasAttemptedLoad = true
}
} else {
await MainActor.run {
cachedImage = nil
hasAttemptedLoad = true
}
}
}
}

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

@@ -9,6 +9,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let id: String
let name: String
let translations: [String: String]
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
@@ -39,8 +40,22 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return String(format: "%.1fA", current)
}
var localizedName: String {
localizedName(usingPreferredLanguages: Locale.preferredLanguages) ?? name
}
func localizedName(usingPreferredLanguages languages: [String]) -> String? {
guard let primaryIdentifier = languages.first else { return nil }
let locale = Locale(identifier: primaryIdentifier)
return translation(for: locale)
}
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.regionCode)
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name
}
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
@@ -64,6 +79,99 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return affiliateLinks.first
}
private func translation(for locale: Locale) -> String? {
guard !translations.isEmpty else { return nil }
let lookupKeys = ComponentLibraryItem.lookupKeys(for: locale)
for key in lookupKeys {
if let match = translations[key] {
return match
}
}
let normalizedTranslations = translations.reduce(into: [String: String]()) { result, element in
let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(element.key)
result[normalizedKey] = element.value
if let languageOnlyKey = ComponentLibraryItem.languageComponent(fromNormalizedKey: normalizedKey),
result[languageOnlyKey] == nil {
result[languageOnlyKey] = element.value
}
}
for key in lookupKeys {
let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(key)
if let match = normalizedTranslations[normalizedKey] {
return match
}
}
return nil
}
private static func lookupKeys(for locale: Locale) -> [String] {
var keys: [String] = []
func append(_ value: String?) {
guard let value, !value.isEmpty else { return }
for variant in variants(for: value) {
if !keys.contains(variant) {
keys.append(variant)
}
}
}
append(locale.identifier)
if let languageCode = locale.language.languageCode?.identifier.lowercased() {
append(languageCode)
}
if let regionIdentifier = locale.region?.identifier.uppercased(),
let languageIdentifier = locale.language.languageCode?.identifier.lowercased() {
append("\(languageIdentifier)_\(regionIdentifier)")
}
return keys
}
private static func normalizeLocaleKey(_ key: String) -> String {
let sanitized = key.replacingOccurrences(of: "-", with: "_")
let parts = sanitized.split(separator: "_", omittingEmptySubsequences: true)
guard let languagePart = parts.first else {
return sanitized.lowercased()
}
let language = languagePart.lowercased()
if parts.count >= 2, let regionPart = parts.last {
return "\(language)_\(regionPart.uppercased())"
}
return language
}
private static func languageComponent(fromNormalizedKey key: String) -> String? {
let components = key.split(separator: "_", omittingEmptySubsequences: true)
guard let language = components.first else { return nil }
return String(language)
}
private static func variants(for key: String) -> [String] {
var collected: [String] = []
let underscore = key.replacingOccurrences(of: "-", with: "_")
let hyphen = key.replacingOccurrences(of: "_", with: "-")
for candidate in Set([key, underscore, hyphen]) {
collected.append(candidate)
}
return collected
}
}
@MainActor
@@ -101,32 +209,59 @@ final class ComponentLibraryViewModel: ObservableObject {
}
private func fetchComponents() async throws -> [ComponentLibraryItem] {
var components = URLComponents(url: baseURL.appendingPathComponent("api/collections/components/records"), resolvingAgainstBaseURL: false)
components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"),
URLQueryItem(name: "sort", value: "+name"),
URLQueryItem(name: "fields", value: "id,collectionId,name,icon,voltage_in,voltage_out,watt")
]
let perPage = 200
var page = 1
var allRecords: [PocketBaseRecord] = []
guard let url = components?.url else {
throw URLError(.badURL)
while true {
var components = URLComponents(
url: baseURL.appendingPathComponent("api/collections/components/records"),
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "filter", value: "(type='load')"),
URLQueryItem(name: "sort", value: "+name"),
URLQueryItem(name: "fields", value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt"),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "perPage", value: "\(perPage)")
]
guard let url = components?.url else {
throw URLError(.badURL)
}
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
allRecords.append(contentsOf: decoded.items)
let isLastPage: Bool
if let totalPages = decoded.totalPages, totalPages > 0 {
isLastPage = page >= totalPages
} else {
isLastPage = decoded.items.count < perPage
}
if isLastPage {
break
}
page += 1
}
var request = URLRequest(url: url)
request.cachePolicy = .reloadIgnoringLocalCacheData
let (data, response) = try await urlSession.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw URLError(.badServerResponse)
}
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: decoded.items.map(\.id))
let mappedItems = decoded.items.map { record in
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
let mappedItems = allRecords.map { record in
ComponentLibraryItem(
id: record.id,
name: record.name,
translations: record.translations?.flattened ?? [:],
voltageIn: record.voltageIn,
voltageOut: record.voltageOut,
watt: record.watt,
@@ -264,6 +399,9 @@ final class ComponentLibraryViewModel: ObservableObject {
}
private struct PocketBaseResponse: Decodable {
let page: Int?
let perPage: Int?
let totalPages: Int?
let items: [PocketBaseRecord]
}
@@ -271,6 +409,7 @@ final class ComponentLibraryViewModel: ObservableObject {
let id: String
let collectionId: String
let name: String
let translations: TranslationsContainer?
let icon: String?
let voltageIn: Double?
let voltageOut: Double?
@@ -280,11 +419,64 @@ final class ComponentLibraryViewModel: ObservableObject {
case id
case collectionId
case name
case translations
case icon
case voltageIn = "voltage_in"
case voltageOut = "voltage_out"
case watt
}
struct TranslationsContainer: Decodable {
private let storage: [String: TranslationValue]
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
storage = try container.decode([String: TranslationValue].self)
}
var flattened: [String: String] {
storage.reduce(into: [:]) { result, entry in
if let value = entry.value.flattened {
result[entry.key] = value
}
}
}
}
private enum TranslationValue: Decodable {
case string(String)
case dictionary([String: String])
init(from decoder: Decoder) throws {
let singleValue = try decoder.singleValueContainer()
if let string = try? singleValue.decode(String.self) {
self = .string(string)
return
}
if let dictionary = try? singleValue.decode([String: String].self) {
self = .dictionary(dictionary)
return
}
self = .dictionary([:])
}
var flattened: String? {
switch self {
case .string(let value):
return value.isEmpty ? nil : value
case .dictionary(let dictionary):
if let name = dictionary["name"], !name.isEmpty {
return name
}
if let value = dictionary["value"], !value.isEmpty {
return value
}
return dictionary.values.first(where: { !$0.isEmpty })
}
}
}
}
private struct AffiliateLinksResponse: Decodable {
@@ -317,6 +509,7 @@ struct ComponentLibraryView: View {
Button("Close") {
dismiss()
}
.accessibilityIdentifier("library-view-close-button")
}
}
}
@@ -366,14 +559,17 @@ struct ComponentLibraryView: View {
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
List(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
List {
ForEach(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
poweredByVoltplanRow
}
.listStyle(.insetGrouped)
}
@@ -384,7 +580,30 @@ struct ComponentLibraryView: View {
guard !trimmedQuery.isEmpty else { return viewModel.items }
return viewModel.items.filter { item in
item.name.localizedCaseInsensitiveContains(trimmedQuery)
let localizedName = item.localizedName
return localizedName.localizedCaseInsensitiveContains(trimmedQuery)
|| item.name.localizedCaseInsensitiveContains(trimmedQuery)
}
}
@ViewBuilder
private var poweredByVoltplanRow: some View {
if let url = URL(string: "https://voltplan.app") {
Section {
Link(destination: url) {
Image("PoweredByVoltplan")
.renderingMode(.original)
.resizable()
.scaledToFit()
.frame(maxWidth: 220)
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
.accessibilityLabel("Powered by Voltplan")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.textCase(nil)
}
}
}
@@ -396,7 +615,7 @@ private struct ComponentRow: View {
HStack(spacing: 12) {
iconView
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
Text(item.localizedName)
.font(.headline)
.foregroundColor(.primary)
detailLine

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

@@ -23,9 +23,9 @@ struct LoadEditorView: View {
var body: some View {
ItemEditorView(
title: "Edit Load",
nameFieldLabel: "Load name",
previewSubtitle: "Preview",
title: String(localized: "editor.load.title", comment: "Title for the load editor"),
nameFieldLabel: String(localized: "editor.load.name_field", comment: "Label for the load name text field"),
previewSubtitle: String(localized: "editor.load.preview", comment: "Placeholder subtitle in the load editor preview"),
icons: loadIcons,
name: $loadName,
iconName: $iconName,
@@ -36,10 +36,10 @@ struct LoadEditorView: View {
}
#Preview {
@State var name = "My Load"
@State var icon = "lightbulb"
@State var color = "blue"
@State var remoteIcon: String? = "https://example.com/icon.png"
@Previewable @State var name = "My Load"
@Previewable @State var icon = "lightbulb"
@Previewable @State var color = "blue"
@Previewable @State var remoteIcon: String? = "https://example.com/icon.png"
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color, remoteIconURLString: $remoteIcon)
}

View File

@@ -0,0 +1,151 @@
import Foundation
import SwiftUI
import UIKit
struct LoadIconView: View {
let remoteIconURLString: String?
let fallbackSystemName: String
let fallbackColor: Color
let size: CGFloat
private var cornerRadius: CGFloat {
max(6, size / 4)
}
@State private var cachedImage: Image?
@State private var isLoading = false
@State private var hasAttemptedLoad = false
var body: some View {
Group {
if let cachedImage {
cachedImage
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
} else if let url = remoteURL, !hasAttemptedLoad {
ProgressView()
.frame(width: size, height: size)
.task(id: url) {
await loadImage(from: url)
}
} else {
fallbackView
}
}
.frame(width: size, height: size)
.onChange(of: remoteIconURLString) { oldValue, newValue in
guard oldValue != newValue else { return }
cachedImage = nil
hasAttemptedLoad = false
}
}
private var fallbackView: some View {
ZStack {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(fallbackColor)
Image(systemName: fallbackSystemName.isEmpty ? "lightbulb" : fallbackSystemName)
.font(.system(size: size * 0.5))
.foregroundColor(.white)
}
}
private var remoteURL: URL? {
guard let remoteIconURLString, let url = URL(string: remoteIconURLString) else { return nil }
return url
}
private func loadImage(from url: URL) async {
guard !isLoading else { return }
isLoading = true
defer { isLoading = false }
if let uiImage = try? await IconCache.shared.image(for: url) {
await MainActor.run {
cachedImage = Image(uiImage: uiImage)
hasAttemptedLoad = true
}
} else {
await MainActor.run {
cachedImage = nil
hasAttemptedLoad = true
}
}
}
}
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

@@ -0,0 +1,32 @@
import SwiftUI
struct OnboardingCarouselView: View {
let images: [String]
let step: Int
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let height = geometry.size.height
ZStack {
if images.isEmpty {
Image(systemName: "photo")
.font(.largeTitle)
.foregroundStyle(Color.secondary)
} else {
HStack(spacing: 0) {
ForEach(Array(images.enumerated()), id: \.offset) { _, name in
Image(name)
.resizable()
.scaledToFit()
.frame(width: width, height: height)
}
}
.offset(x: -CGFloat(step) * width)
}
}
.clipped()
}
}
}

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

82
Cable/SavedBattery.swift Normal file
View File

@@ -0,0 +1,82 @@
import Foundation
import SwiftData
@Model
class SavedBattery {
@Attribute(.unique) var 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?
private var chemistryRawValue: String
var iconName: String = "battery.100"
var colorName: String = "blue"
var system: ElectricalSystem?
var bomCompletedItemIDs: [String] = []
var timestamp: Date
init(
id: UUID = UUID(),
name: String,
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
}
var chemistry: BatteryConfiguration.Chemistry {
get {
BatteryConfiguration.Chemistry(rawValue: chemistryRawValue) ?? .lithiumIronPhosphate
}
set {
chemistryRawValue = newValue.rawValue
}
}
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
}
}

212
Cable/SettingsView.swift Normal file
View File

@@ -0,0 +1,212 @@
//
// SettingsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
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 {
Form {
Section("Units") {
Picker("Unit System", selection: $unitSettings.unitSystem) {
ForEach(UnitSystem.allCases, id: \.self) { system in
Text(system.displayName).tag(system)
}
}
.pickerStyle(.segmented)
}
Section("Cable PRO") {
proSectionContent
}
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 18))
Text("Safety Disclaimer")
.font(.headline)
.fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: 8) {
Text("This application provides electrical calculations for educational and estimation purposes only.")
.font(.body)
Text("Important:")
.font(.subheadline)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) {
Text("• Always consult qualified electricians for actual installations")
Text("• Follow all local electrical codes and regulations")
Text("• Electrical work should only be performed by licensed professionals")
Text("• These calculations may not account for all environmental factors")
Text("• The app developers assume no liability for electrical installations")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
}
}
}
.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

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

@@ -0,0 +1,270 @@
import Foundation
import SwiftUI
import SwiftData
struct SystemComponentsPersistence {
static func createDefaultLoad(
for system: ElectricalSystem,
in context: ModelContext,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery],
existingChargers: [SavedCharger]
) -> SavedLoad {
let defaultName = String(
localized: "default.load.new",
comment: "Default name when creating a new load from system view"
)
let loadName = uniqueName(
startingWith: defaultName,
loads: existingLoads,
batteries: existingBatteries,
chargers: existingChargers
)
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
current: 5.0,
power: 60.0,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
system: system,
remoteIconURLString: nil
)
context.insert(newLoad)
return newLoad
}
static func createLoad(
from item: ComponentLibraryItem,
for system: ElectricalSystem,
in context: ModelContext,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery],
existingChargers: [SavedCharger]
) -> SavedLoad {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
let loadName = uniqueName(
startingWith: baseName,
loads: existingLoads,
batteries: existingBatteries,
chargers: existingChargers
)
let voltage = item.displayVoltage ?? 12.0
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
context.insert(newLoad)
return newLoad
}
static func makeBatteryDraft(
for system: ElectricalSystem,
existingLoads: [SavedLoad],
existingBatteries: [SavedBattery],
existingChargers: [SavedCharger]
) -> BatteryConfiguration {
let defaultName = NSLocalizedString(
"battery.editor.default_name",
bundle: .main,
value: "New Battery",
comment: "Default name when configuring a new battery"
)
let batteryName = uniqueName(
startingWith: defaultName,
loads: existingLoads,
batteries: existingBatteries,
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
)
}
static func saveBattery(
_ configuration: BatteryConfiguration,
for system: ElectricalSystem,
existingBatteries: [SavedBattery],
in context: ModelContext
) {
if let existing = existingBatteries.first(where: { $0.id == configuration.id }) {
configuration.apply(to: existing)
} else {
let newBattery = SavedBattery(
id: configuration.id,
name: configuration.name,
nominalVoltage: configuration.nominalVoltage,
capacityAmpHours: configuration.capacityAmpHours,
chemistry: configuration.chemistry,
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],
in context: ModelContext
) {
for index in offsets {
context.delete(batteries[index])
}
}
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],
chargers: [SavedCharger]
) -> String {
let existingNames = Set(
loads.map { $0.name } +
batteries.map { $0.name } +
chargers.map { $0.name }
)
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
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

@@ -32,17 +32,21 @@ struct SystemEditorView: View {
}
var body: some View {
let editorTitle = String(localized: "editor.system.title", comment: "Title for the system editor")
let namePlaceholder = String(localized: "editor.system.name_field", comment: "Label for the system name text field")
let locationPlaceholder = String(localized: "editor.system.location.optional", comment: "Placeholder text shown when no location is specified")
ItemEditorView(
title: "Edit System",
nameFieldLabel: "System name",
previewSubtitle: tempLocation.isEmpty ? "Location (optional)" : tempLocation,
title: editorTitle,
nameFieldLabel: namePlaceholder,
previewSubtitle: tempLocation.isEmpty ? locationPlaceholder : tempLocation,
icons: systemIcons,
name: $systemName,
iconName: $iconName,
colorName: $colorName,
additionalFields: {
AnyView(
TextField("Location (optional)", text: $tempLocation)
TextField(locationPlaceholder, text: $tempLocation)
.autocapitalization(.words)
.onChange(of: tempLocation) { _, newValue in
location = newValue
@@ -57,10 +61,10 @@ struct SystemEditorView: View {
}
#Preview {
@State var name = "My System"
@State var location = "Main Building"
@State var icon = "building.2"
@State var color = "blue"
@Previewable @State var name = "My System"
@Previewable @State var location = "Main Building"
@Previewable @State var icon = "building.2"
@Previewable @State var color = "blue"
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
}

View File

@@ -0,0 +1,133 @@
import SwiftUI
struct SystemsOnboardingView: View {
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
@State private var carouselStep = 0
@FocusState private var isFieldFocused: Bool
let onCreate: (String) -> Void
private let imageNames = [
"van-onboarding",
"cabin-onboarding",
"boat-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("Create your first system")
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text("Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place.")
.font(.body)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
.frame(minHeight: 96)
.padding(.horizontal, 12)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 16) {
HStack(spacing: 12) {
Image(systemName: "sparkles")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(isFieldFocused ? Color.accentColor : Color.accentColor.opacity(0.6))
TextField("System Name", text: $systemName)
.textInputAutocapitalization(.words)
.disableAutocorrection(true)
.focused($isFieldFocused)
.submitLabel(.done)
.onSubmit(createSystem)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.secondarySystemBackground))
)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(isFieldFocused ? Color.accentColor : Color.clear, lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.08), radius: 12, x: 0, y: 6)
.animation(.easeInOut(duration: 0.2), value: isFieldFocused)
Button(action: createSystem) {
HStack(spacing:8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
Text("Create System")
.font(.headline.weight(.semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Color.blue)
.cornerRadius(12)
}
.accessibilityIdentifier("create-system-button")
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
}
.padding(.bottom, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
.task {
AnalyticsTracker.log("Launched")
}
}
private func resetState() {
systemName = String(localized: "default.system.name", comment: "Default placeholder name for a system")
carouselStep = 0
}
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)
}
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 {
SystemsOnboardingView { _ in }
}

View File

@@ -0,0 +1,598 @@
//
// SystemsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@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",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
]
private let defaultSystemIconName = "building.2"
private var systemIconMappings: [(keywords: [String], icon: String)] {
[
(keywords(for: "system.icon.keywords.rv", fallback: ["rv", "van", "camper", "motorhome", "coach"]), "bus"),
(keywords(for: "system.icon.keywords.truck", fallback: ["truck", "trailer", "rig"]), "truck.box"),
(keywords(for: "system.icon.keywords.boat", fallback: ["boat", "marine", "yacht", "sail"]), "sailboat"),
(keywords(for: "system.icon.keywords.plane", fallback: ["plane", "air", "flight"]), "airplane"),
(keywords(for: "system.icon.keywords.ferry", fallback: ["ferry", "ship"]), "ferry"),
(keywords(for: "system.icon.keywords.house", fallback: ["house", "home", "cabin", "cottage", "lodge"]), "house"),
(keywords(for: "system.icon.keywords.building", fallback: ["building", "office", "warehouse", "factory", "facility"]), "building"),
(keywords(for: "system.icon.keywords.tent", fallback: ["camp", "tent", "outdoor"]), "tent"),
(keywords(for: "system.icon.keywords.solar", fallback: ["solar", "sun"]), "sun.max"),
(keywords(for: "system.icon.keywords.battery", fallback: ["battery", "storage"]), "battery.100"),
(keywords(for: "system.icon.keywords.server", fallback: ["server", "data", "network", "rack"]), "server.rack"),
(keywords(for: "system.icon.keywords.computer", fallback: ["computer", "electronics", "lab", "tech"]), "cpu"),
(keywords(for: "system.icon.keywords.gear", fallback: ["gear", "mechanic", "machine", "workshop"]), "gear"),
(keywords(for: "system.icon.keywords.tool", fallback: ["tool", "maintenance", "repair", "shop"]), "wrench.adjustable"),
(keywords(for: "system.icon.keywords.hammer", fallback: ["hammer", "carpentry"]), "hammer"),
(keywords(for: "system.icon.keywords.light", fallback: ["light", "lighting", "lamp"]), "lightbulb"),
(keywords(for: "system.icon.keywords.bolt", fallback: ["bolt", "power", "electric"]), "bolt"),
(keywords(for: "system.icon.keywords.plug", fallback: ["plug"]), "powerplug"),
(keywords(for: "system.icon.keywords.engine", fallback: ["engine", "generator", "motor"]), "engine.combustion"),
(keywords(for: "system.icon.keywords.fuel", fallback: ["fuel", "diesel", "gas"]), "fuelpump"),
(keywords(for: "system.icon.keywords.water", fallback: ["water", "pump", "tank"]), "drop"),
(keywords(for: "system.icon.keywords.heat", fallback: ["heat", "heater", "furnace"]), "flame"),
(keywords(for: "system.icon.keywords.cold", fallback: ["cold", "freeze", "cool"]), "snowflake"),
(keywords(for: "system.icon.keywords.climate", fallback: ["climate", "hvac", "temperature"]), "thermometer")
]
}
private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID()
let system: ElectricalSystem
let presentSystemEditor: Bool
let loadToOpenOnAppear: SavedLoad?
static func == (lhs: SystemNavigationTarget, rhs: SystemNavigationTarget) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
var body: some View {
NavigationStack {
Group {
if systems.isEmpty {
systemsEmptyState
} else {
List {
ForEach(systems) { system in
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(Color.componentColor(named: system.colorName))
.frame(width: 44, height: 44)
Image(systemName: system.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundColor(.secondary.opacity(0.6))
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func createNewSystem() {
let system = makeSystem()
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)
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) {
let system = makeSystem(
preferredName: name,
colorName: randomSystemColorName()
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "onboarding"
)
}
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,
loadToOpenOnAppear: loadToOpen
)
if animated {
systemNavigationTarget = target
} else {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
systemNavigationTarget = target
}
}
}
@discardableResult
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
let existingNames = Set(systems.map { $0.name })
let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let baseName = trimmedPreferred.isEmpty ? String(localized: "default.system.new", comment: "Default name for a newly created system") : trimmedPreferred
var systemName = baseName
var counter = 2
while existingNames.contains(systemName) {
systemName = "\(baseName) \(counter)"
counter += 1
}
let resolvedColorName = colorName ?? "blue"
let resolvedIconName = iconName ?? systemIconName(for: systemName)
let newSystem = ElectricalSystem(
name: systemName,
location: "",
iconName: resolvedIconName,
colorName: resolvedColorName
)
modelContext.insert(newSystem)
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)
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 {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty
? String(localized: "default.load.library", comment: "Default name when importing a library load")
: localizedName
let loadName = uniqueLoadName(for: system, startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
let power: Double
if let watt = item.watt {
power = watt
} else if let derivedCurrent = item.current, voltage > 0 {
power = derivedCurrent * voltage
} else {
power = 0
}
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
return newLoad
}
private func uniqueLoadName(for system: ElectricalSystem, startingWith baseName: String) -> String {
let descriptor = FetchDescriptor<SavedLoad>()
let fetchedLoads = (try? modelContext.fetch(descriptor)) ?? []
let existingNames = Set(fetchedLoads.filter { $0.system == system }.map { $0.name })
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
private func deleteSystems(offsets: IndexSet) {
let systemsToDelete = offsets.map { systems[$0] }
withAnimation {
for system in systemsToDelete {
AnalyticsTracker.log(
"System Deleted",
properties: [
"name": system.name,
"loads": loads(for: system).count
]
)
deleteLoads(for: system)
modelContext.delete(system)
}
}
}
private func deleteLoads(for system: ElectricalSystem) {
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)
}
}
}
private func loads(for system: ElectricalSystem) -> [SavedLoad] {
allLoads.filter { $0.system == system }
}
private func componentSummary(for system: ElectricalSystem) -> String {
let systemLoads = loads(for: system)
guard !systemLoads.isEmpty else {
return String(localized: "system.list.no.components", comment: "Message shown when a system has no components yet")
}
let count = systemLoads.count
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
let formattedPower: String
if totalPower >= 1000 {
formattedPower = String(format: "%.1fkW", totalPower / 1000)
} else {
formattedPower = String(format: "%.0fW", totalPower)
}
let format = NSLocalizedString(
"system.list.component.summary",
comment: "Summary showing number of components and the total power"
)
return String.localizedStringWithFormat(format, count, formattedPower)
}
private func randomSystemColorName() -> String {
systemColorOptions.randomElement() ?? "blue"
}
private func systemIconName(for name: String) -> String {
let normalized = name
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
for mapping in systemIconMappings {
if mapping.keywords.contains(where: { keyword in normalized.contains(keyword) }) {
return mapping.icon
}
}
return defaultSystemIconName
}
private func keywords(for localizationKey: String, fallback: [String]) -> [String] {
let fallbackValue = fallback.joined(separator: ",")
let localizedKeywords = NSLocalizedString(
localizationKey,
tableName: nil,
bundle: .main,
value: fallbackValue,
comment: ""
)
let separators = CharacterSet(charactersIn: ",;")
let components = localizedKeywords
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
var uniqueKeywords: [String] = []
for keyword in fallback.map({ $0.lowercased() }) + components {
if !uniqueKeywords.contains(keyword) {
uniqueKeywords.append(keyword)
}
}
return uniqueKeywords
}
}
#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

@@ -0,0 +1,224 @@
//
// UITestSampleData.swift
// Cable
// Created by Stefan Lange-Hegermann on 06.10.25.
import Foundation
import SwiftData
enum UITestSampleData {
static let sampleArgument = "--uitest-sample-data"
static let resetArgument = "--uitest-reset-data"
static func handleLaunchArguments(container: ModelContainer) {
#if DEBUG
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 prepare UI test data: \(error)")
}
#endif
}
}
#if DEBUG
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) }
}
static func seedSampleData(in context: ModelContext) throws {
let adventureVan = ElectricalSystem(
name: String(localized: "sample.system.rv.name", comment: "Sample data name for the adventure van system"),
location: String(localized: "sample.system.rv.location", comment: "Sample data location for the adventure van system"),
iconName: "bus",
colorName: "orange"
)
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
let workshopBench = ElectricalSystem(
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
location: String(localized: "sample.system.workshop.location", comment: "Sample data location for the workshop system"),
iconName: "wrench.adjustable",
colorName: "teal"
)
workshopBench.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
context.insert(adventureVan)
context.insert(workshopBench)
let vanFridge = SavedLoad(
name: String(localized: "sample.load.fridge.name", comment: "Sample data load name for a compressor fridge"),
voltage: 12.0,
current: 4.2,
power: 50.0,
length: 6.0,
crossSection: 6.0,
iconName: "snowflake",
colorName: "blue",
isWattMode: true,
system: adventureVan,
identifier: "sample.load.fridge"
)
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
let vanLighting = SavedLoad(
name: String(localized: "sample.load.lighting.name", comment: "Sample data load name for LED strip lighting"),
voltage: 12.0,
current: 2.0,
power: 24.0,
length: 10.0,
crossSection: 2.5,
iconName: "lightbulb",
colorName: "yellow",
isWattMode: false,
system: adventureVan,
identifier: "sample.load.lighting"
)
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
let workshopCompressor = SavedLoad(
name: String(localized: "sample.load.compressor.name", comment: "Sample data load name for an air compressor"),
voltage: 120.0,
current: 8.0,
power: 960.0,
length: 15.0,
crossSection: 16.0,
iconName: "hammer",
colorName: "red",
isWattMode: true,
system: workshopBench,
identifier: "sample.load.compressor"
)
workshopCompressor.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
let workshopCharger = SavedLoad(
name: String(localized: "sample.load.charger.name", comment: "Sample data load name for a tool charger"),
voltage: 120.0,
current: 3.5,
power: 420.0,
length: 8.0,
crossSection: 10.0,
iconName: "battery.100",
colorName: "green",
isWattMode: false,
system: workshopBench,
identifier: "sample.load.charger"
)
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

@@ -14,9 +14,9 @@ enum UnitSystem: String, CaseIterable {
var displayName: String {
switch self {
case .metric:
return "Metric (mm², m)"
return String(localized: "units.metric.display", comment: "Display name for the metric unit system")
case .imperial:
return "Imperial (AWG, ft)"
return String(localized: "units.imperial.display", comment: "Display name for the imperial unit system")
}
}
@@ -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

@@ -0,0 +1,391 @@
"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";
"bom.item.cable.black" = "Stromkabel (schwarz)";
"bom.item.cable.red" = "Stromkabel (rot)";
"bom.item.fuse" = "Sicherung & Halter";
"bom.item.terminals" = "Kabelschuhe / Klemmen";
"bom.navigation.title" = "Stückliste";
"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.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";
"editor.load.preview" = "Vorschau";
"editor.load.title" = "Verbraucher bearbeiten";
"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.icon.keywords.battery" = "batterie, speicher, akku";
"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot";
"system.icon.keywords.bolt" = "strom, power, elektrisch, spannung";
"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage";
"system.icon.keywords.climate" = "klima, hvac, temperatur";
"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";
"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

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d Komponente</string>
<key>other</key>
<string>%d Komponenten</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,353 @@
// Keys
"affiliate.button.review_parts" = "Revisar componentes";
"affiliate.description.with_link" = "Al tocar verás una lista completa de materiales antes de abrir el enlace de afiliado. Las compras pueden apoyar a VoltPlan.";
"affiliate.description.without_link" = "Al tocar verás una lista completa de materiales con búsquedas de compra para ayudarte a conseguir piezas.";
"affiliate.disclaimer" = "Las compras a través de enlaces de afiliados pueden apoyar a VoltPlan.";
"bom.accessibility.mark.complete" = "Marcar %@ como completado";
"bom.accessibility.mark.incomplete" = "Marcar %@ como pendiente";
"bom.fuse.detail" = "Portafusibles en línea y fusible de %d A";
"bom.item.cable.black" = "Cable de alimentación (negro)";
"bom.item.cable.red" = "Cable de alimentación (rojo)";
"bom.item.fuse" = "Fusible y portafusibles";
"bom.item.terminals" = "Terminales / zapatas";
"bom.navigation.title" = "Lista de materiales";
"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";
"default.load.unnamed" = "Carga sin nombre";
"default.load.new" = "Carga nueva";
"default.system.name" = "Mi sistema";
"default.system.new" = "Sistema nuevo";
"editor.load.name_field" = "Nombre de la carga";
"editor.load.preview" = "Vista previa";
"editor.load.title" = "Editar carga";
"editor.system.location.optional" = "Ubicación (opcional)";
"editor.system.name_field" = "Nombre del sistema";
"editor.system.title" = "Editar sistema";
"slider.button.ampere" = "Amperios";
"slider.button.watt" = "Vatios";
"slider.current.title" = "Corriente";
"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)";
"sample.system.rv.name" = "Furgoneta aventura";
"sample.system.rv.location" = "Circuito de vivienda 12V";
"sample.system.workshop.name" = "Banco de taller";
"sample.system.workshop.location" = "Rincón de herramientas";
"sample.load.fridge.name" = "Nevera de compresor";
"sample.load.lighting.name" = "Iluminación LED";
"sample.load.compressor.name" = "Compresor de aire";
"sample.load.charger.name" = "Cargador de herramientas";
"system.icon.keywords.rv" = "autocaravana, camper, caravana, furgo, van";
"system.icon.keywords.truck" = "camión, remolque, tráiler";
"system.icon.keywords.boat" = "barco, embarcación, yate, vela";
"system.icon.keywords.plane" = "avión, vuelo, aire";
"system.icon.keywords.ferry" = "ferry, transbordador, barco";
"system.icon.keywords.house" = "casa, hogar, cabaña, chalet";
"system.icon.keywords.building" = "edificio, oficina, almacén, fábrica, instalación";
"system.icon.keywords.tent" = "camping, tienda, exterior";
"system.icon.keywords.solar" = "solar, sol, fotovoltaico";
"system.icon.keywords.battery" = "batería, almacenamiento, acumulador";
"system.icon.keywords.server" = "servidor, datos, red, rack";
"system.icon.keywords.computer" = "computadora, ordenador, electrónica, laboratorio, tecnología";
"system.icon.keywords.gear" = "engranaje, mecánica, máquina, taller";
"system.icon.keywords.tool" = "herramienta, mantenimiento, reparación, taller";
"system.icon.keywords.hammer" = "martillo, carpintería";
"system.icon.keywords.light" = "luz, iluminación, lámpara";
"system.icon.keywords.bolt" = "volt, energía, eléctrico, potencia";
"system.icon.keywords.plug" = "enchufe, clavija";
"system.icon.keywords.engine" = "motor, generador";
"system.icon.keywords.fuel" = "combustible, diésel, gasolina";
"system.icon.keywords.water" = "agua, bomba, tanque, depósito";
"system.icon.keywords.heat" = "calor, calefacción, horno";
"system.icon.keywords.cold" = "frío, congelar, enfriar";
"system.icon.keywords.climate" = "clima, hvac, temperatura";
// Direct strings
"Systems" = "Sistemas";
"System" = "Sistema";
"System View" = "Vista del sistema";
"System Name" = "Nombre del sistema";
"Create System" = "Crear sistema";
"Create your first system" = "Crea tu primer sistema";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ponle un nombre a tu sistema para que **Cable by VoltPlan** organice cargas, cableado y recomendaciones en un solo lugar.";
"Add your first component" = "Añade tu primer componente";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Da vida a tu sistema con componentes y deja que **Cable by VoltPlan** se encargue de recomendar cables y fusibles.";
"Create Component" = "Crear componente";
"Browse Library" = "Explorar biblioteca";
"Browse" = "Explorar";
"Browse electrical components from VoltPlan" = "Explora los componentes eléctricos de VoltPlan";
"Component Library" = "Biblioteca de componentes";
"Details coming soon" = "Detalles próximamente";
"Components" = "Componentes";
"FUSE" = "FUSIBLE";
"WIRE" = "CABLE";
"Current" = "Corriente";
"Power" = "Potencia";
"Voltage" = "Voltaje";
"Length" = "Longitud";
"Length:" = "Longitud:";
"Wire Cross-Section:" = "Sección del cable:";
"Current Units" = "Unidades actuales";
"Unit System" = "Sistema de unidades";
"Units" = "Unidades";
"Settings" = "Ajustes";
"Close" = "Cerrar";
"Cancel" = "Cancelar";
"Save" = "Guardar";
"Retry" = "Reintentar";
"Loading components" = "Cargando componentes";
"Unable to load components" = "No se pudieron cargar los componentes";
"No components available" = "No hay componentes disponibles";
"No matches" = "Sin coincidencias";
"Check back soon for new loads from VoltPlan." = "Vuelve pronto para encontrar nuevas cargas de VoltPlan.";
"Try searching for a different name." = "Prueba a buscar otro nombre.";
"Search components" = "Buscar componentes";
"No loads saved in this system yet." = "Todavía no hay cargas guardadas en este sistema.";
"Coming soon - manage your electrical systems and panels here." = "Próximamente: gestiona aquí tus sistemas y paneles eléctricos.";
"Load Library" = "Biblioteca de cargas";
"Safety Disclaimer" = "Aviso de seguridad";
"This application provides electrical calculations for educational and estimation purposes only." = "Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.";
"Important:" = "Importante:";
"• Always consult qualified electricians for actual installations" = "• Consulta siempre a electricistas calificados para las instalaciones reales";
"• Follow all local electrical codes and regulations" = "• Cumple todas las normativas y códigos eléctricos locales";
"• Electrical work should only be performed by licensed professionals" = "• Los trabajos eléctricos solo deben realizarlos profesionales autorizados";
"• These calculations may not account for all environmental factors" = "• Estos cálculos pueden no tener en cuenta todos los factores ambientales";
"• The app developers assume no liability for electrical installations" = "• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas";
"Enter length in %@" = "Introduce la longitud en %@";
"Enter voltage in volts (V)" = "Introduce el voltaje en voltios (V)";
"Enter current in amperes (A)" = "Introduce la corriente en amperios (A)";
"Enter power in watts (W)" = "Introduce la potencia en vatios (W)";
"Edit Length" = "Editar longitud";
"Edit Voltage" = "Editar voltaje";
"Edit Current" = "Editar corriente";
"Edit Power" = "Editar potencia";
"Preview" = "Vista previa";
"Details" = "Detalles";
"Icon" = "Icono";
"Color" = "Color";
"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";
"battery.bank.banner.capacity" = "Se detectó un desajuste de capacidad";
"battery.bank.empty.title" = "Sin baterías todavía";
"battery.bank.empty.subtitle" = "Toca el botón más para configurar una batería para %@.";
"battery.bank.status.dismiss" = "Entendido";
"battery.bank.status.single.battery" = "Una batería";
"battery.bank.status.multiple.batteries" = "%d baterías";
"battery.bank.status.voltage.title" = "Desajuste de voltaje";
"battery.bank.status.voltage.message" = "%@ se desvía del voltaje base del banco %@. Mezclar voltajes nominales provoca carga desigual y puede dañar cargadores o inversores conectados.";
"battery.bank.status.capacity.title" = "Desajuste de capacidad";
"battery.bank.status.capacity.message" = "%@ usa una capacidad distinta del valor dominante del banco %@. Las capacidades desiguales provocan descargas irregulares y desgaste prematuro.";
"battery.editor.title" = "Configuración de batería";
"battery.editor.cancel" = "Cancelar";
"battery.editor.save" = "Guardar";
"battery.editor.field.name" = "Nombre";
"battery.editor.placeholder.name" = "Banco principal";
"battery.editor.field.chemistry" = "Química";
"battery.editor.section.summary" = "Resumen";
"battery.editor.slider.voltage" = "Voltaje nominal";
"battery.editor.slider.capacity" = "Capacidad";
"battery.editor.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

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d componente</string>
<key>other</key>
<string>%d componentes</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,353 @@
// Keys
"affiliate.button.review_parts" = "Examiner les composants";
"affiliate.description.with_link" = "En appuyant, vous verrez une liste complète de matériel avant d'ouvrir le lien d'affiliation. Les achats peuvent soutenir VoltPlan.";
"affiliate.description.without_link" = "En appuyant, vous verrez une liste complète de matériel avec des recherches d'achat pour vous aider à trouver les pièces.";
"affiliate.disclaimer" = "Les achats effectués via des liens d'affiliation peuvent soutenir VoltPlan.";
"bom.accessibility.mark.complete" = "Marquer %@ comme terminé";
"bom.accessibility.mark.incomplete" = "Marquer %@ comme à faire";
"bom.fuse.detail" = "Porte-fusible en ligne et fusible de %d A";
"bom.item.cable.black" = "Câble d'alimentation (noir)";
"bom.item.cable.red" = "Câble d'alimentation (rouge)";
"bom.item.fuse" = "Fusible et porte-fusible";
"bom.item.terminals" = "Cosses / bornes";
"bom.navigation.title" = "Liste de matériel";
"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";
"default.load.unnamed" = "Charge sans nom";
"default.load.new" = "Nouvelle charge";
"default.system.name" = "Mon système";
"default.system.new" = "Nouveau système";
"editor.load.name_field" = "Nom de la charge";
"editor.load.preview" = "Aperçu";
"editor.load.title" = "Modifier la charge";
"editor.system.location.optional" = "Emplacement (optionnel)";
"editor.system.name_field" = "Nom du système";
"editor.system.title" = "Modifier le système";
"slider.button.ampere" = "Ampères";
"slider.button.watt" = "Watts";
"slider.current.title" = "Courant";
"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)";
"sample.system.rv.name" = "Van d'aventure";
"sample.system.rv.location" = "Circuit de vie 12 V";
"sample.system.workshop.name" = "Établi d'atelier";
"sample.system.workshop.location" = "Coin outils";
"sample.load.fridge.name" = "Réfrigérateur à compresseur";
"sample.load.lighting.name" = "Éclairage LED";
"sample.load.compressor.name" = "Compresseur d'air";
"sample.load.charger.name" = "Chargeur d'outils";
"system.icon.keywords.rv" = "camping-car, van, fourgon, caravane, motorhome";
"system.icon.keywords.truck" = "camion, remorque, poids lourd";
"system.icon.keywords.boat" = "bateau, marine, yacht, voile";
"system.icon.keywords.plane" = "avion, vol, air";
"system.icon.keywords.ferry" = "ferry, traversier, bateau";
"system.icon.keywords.house" = "maison, foyer, cabane, chalet, lodge";
"system.icon.keywords.building" = "bâtiment, bureau, entrepôt, usine, installation";
"system.icon.keywords.tent" = "camping, tente, plein air";
"system.icon.keywords.solar" = "solaire, soleil";
"system.icon.keywords.battery" = "batterie, stockage, accumulateur";
"system.icon.keywords.server" = "serveur, données, réseau, rack";
"system.icon.keywords.computer" = "ordinateur, électronique, labo, techno";
"system.icon.keywords.gear" = "engrenage, mécanique, machine, atelier";
"system.icon.keywords.tool" = "outil, maintenance, réparation, atelier";
"system.icon.keywords.hammer" = "marteau, charpente";
"system.icon.keywords.light" = "lumière, éclairage, lampe";
"system.icon.keywords.bolt" = "courant, énergie, électrique";
"system.icon.keywords.plug" = "prise, fiche";
"system.icon.keywords.engine" = "moteur, générateur";
"system.icon.keywords.fuel" = "carburant, diesel, essence";
"system.icon.keywords.water" = "eau, pompe, réservoir";
"system.icon.keywords.heat" = "chaleur, chauffage, chaudière, four";
"system.icon.keywords.cold" = "froid, geler, refroidir";
"system.icon.keywords.climate" = "climat, hvac, température";
// Direct strings
"Systems" = "Systèmes";
"System" = "Système";
"System View" = "Vue système";
"System Name" = "Nom du système";
"Create System" = "Créer un système";
"Create your first system" = "Créez votre premier système";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Donnez un nom à votre installation pour que **Cable by VoltPlan** organise charges, câblage et recommandations au même endroit.";
"Add your first component" = "Ajoutez votre premier composant";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Donnez vie à votre système avec des composants et laissez **Cable by VoltPlan** recommander câbles et fusibles.";
"Create Component" = "Créer un composant";
"Browse Library" = "Parcourir la bibliothèque";
"Browse" = "Parcourir";
"Browse electrical components from VoltPlan" = "Parcourez les composants électriques de VoltPlan";
"Component Library" = "Bibliothèque de composants";
"Details coming soon" = "Détails à venir";
"Components" = "Composants";
"FUSE" = "FUSIBLE";
"WIRE" = "CÂBLE";
"Current" = "Courant";
"Power" = "Puissance";
"Voltage" = "Tension";
"Length" = "Longueur";
"Length:" = "Longueur :";
"Wire Cross-Section:" = "Section du câble :";
"Current Units" = "Unités actuelles";
"Unit System" = "Système d'unités";
"Units" = "Unités";
"Settings" = "Réglages";
"Close" = "Fermer";
"Cancel" = "Annuler";
"Save" = "Enregistrer";
"Retry" = "Réessayer";
"Loading components" = "Chargement des composants";
"Unable to load components" = "Impossible de charger les composants";
"No components available" = "Aucun composant disponible";
"No matches" = "Aucune correspondance";
"Check back soon for new loads from VoltPlan." = "Revenez bientôt pour découvrir de nouvelles charges VoltPlan.";
"Try searching for a different name." = "Essayez une autre recherche.";
"Search components" = "Rechercher des composants";
"No loads saved in this system yet." = "Aucune charge enregistrée pour ce système pour l'instant.";
"Coming soon - manage your electrical systems and panels here." = "Bientôt disponible : gérez ici vos systèmes électriques et vos tableaux.";
"Load Library" = "Bibliothèque de charges";
"Safety Disclaimer" = "Avertissement de sécurité";
"This application provides electrical calculations for educational and estimation purposes only." = "Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d'estimation.";
"Important:" = "Important :";
"• Always consult qualified electricians for actual installations" = "• Faites toujours appel à des électriciens qualifiés pour les installations réelles";
"• Follow all local electrical codes and regulations" = "• Respectez toutes les normes et réglementations électriques locales";
"• Electrical work should only be performed by licensed professionals" = "• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés";
"• These calculations may not account for all environmental factors" = "• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux";
"• The app developers assume no liability for electrical installations" = "• Les développeurs de l'application déclinent toute responsabilité quant aux installations électriques";
"Enter length in %@" = "Saisissez la longueur en %@";
"Enter voltage in volts (V)" = "Saisissez la tension en volts (V)";
"Enter current in amperes (A)" = "Saisissez le courant en ampères (A)";
"Enter power in watts (W)" = "Saisissez la puissance en watts (W)";
"Edit Length" = "Modifier la longueur";
"Edit Voltage" = "Modifier la tension";
"Edit Current" = "Modifier le courant";
"Edit Power" = "Modifier la puissance";
"Preview" = "Aperçu";
"Details" = "Détails";
"Icon" = "Icône";
"Color" = "Couleur";
"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é";
"battery.bank.banner.capacity" = "Écart de capacité détecté";
"battery.bank.empty.title" = "Aucune batterie pour l'instant";
"battery.bank.empty.subtitle" = "Touchez le bouton plus pour configurer une batterie pour %@.";
"battery.bank.status.dismiss" = "Compris";
"battery.bank.status.single.battery" = "Une batterie";
"battery.bank.status.multiple.batteries" = "%d batteries";
"battery.bank.status.voltage.title" = "Écart de tension";
"battery.bank.status.voltage.message" = "%@ s'écarte de la valeur de référence %@ du banc. Mélanger des tensions nominales entraîne une charge inégale et peut endommager les chargeurs ou onduleurs connectés.";
"battery.bank.status.capacity.title" = "Écart de capacité";
"battery.bank.status.capacity.message" = "%@ utilise une capacité différente de la valeur dominante %@ du banc. Des capacités différentes provoquent des décharges inégales et une usure prématurée.";
"battery.editor.title" = "Configuration de la batterie";
"battery.editor.cancel" = "Annuler";
"battery.editor.save" = "Enregistrer";
"battery.editor.field.name" = "Nom";
"battery.editor.placeholder.name" = "Banque principale";
"battery.editor.field.chemistry" = "Chimie";
"battery.editor.section.summary" = "Résumé";
"battery.editor.slider.voltage" = "Tension nominale";
"battery.editor.slider.capacity" = "Capacité";
"battery.editor.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

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d composant</string>
<key>other</key>
<string>%d composants</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,353 @@
// Keys
"affiliate.button.review_parts" = "Onderdelen bekijken";
"affiliate.description.with_link" = "Tik om een volledige materiaallijst te zien voordat de affiliate-link wordt geopend. Aankopen kunnen VoltPlan ondersteunen.";
"affiliate.description.without_link" = "Tik om een volledige materiaallijst te zien met aankoopzoekopdrachten die je helpen onderdelen te vinden.";
"affiliate.disclaimer" = "Aankopen via affiliate-links kunnen VoltPlan ondersteunen.";
"bom.accessibility.mark.complete" = "Markeer %@ als voltooid";
"bom.accessibility.mark.incomplete" = "Markeer %@ als niet voltooid";
"bom.fuse.detail" = "In-line zekeringhouder met zekering van %d A";
"bom.item.cable.black" = "Voedingskabel (zwart)";
"bom.item.cable.red" = "Voedingskabel (rood)";
"bom.item.fuse" = "Zekering en houder";
"bom.item.terminals" = "Kabelschoenen / klemmen";
"bom.navigation.title" = "Materiaallijst";
"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";
"default.load.unnamed" = "Naamloze last";
"default.load.new" = "Nieuwe last";
"default.system.name" = "Mijn systeem";
"default.system.new" = "Nieuw systeem";
"editor.load.name_field" = "Naam van de last";
"editor.load.preview" = "Voorbeeld";
"editor.load.title" = "Last bewerken";
"editor.system.location.optional" = "Locatie (optioneel)";
"editor.system.name_field" = "Naam van het systeem";
"editor.system.title" = "Systeem bewerken";
"slider.button.ampere" = "Ampère";
"slider.button.watt" = "Watt";
"slider.current.title" = "Stroom";
"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)";
"sample.system.rv.name" = "Avonturenbus";
"sample.system.rv.location" = "12V leefcircuit";
"sample.system.workshop.name" = "Werkbank";
"sample.system.workshop.location" = "Gereedschapshoek";
"sample.load.fridge.name" = "Koelbox met compressor";
"sample.load.lighting.name" = "LED-strips";
"sample.load.compressor.name" = "Luchtcompressor";
"sample.load.charger.name" = "Gereedschapslader";
"system.icon.keywords.rv" = "camper, kampeerbus, buscamper, mobilhome, campervan";
"system.icon.keywords.truck" = "vrachtwagen, trailer, aanhanger, truck";
"system.icon.keywords.boat" = "boot, schip, jacht, zeil";
"system.icon.keywords.plane" = "vliegtuig, vlucht, lucht";
"system.icon.keywords.ferry" = "veerboot, ferry, schip";
"system.icon.keywords.house" = "huis, thuis, hut, chalet, lodge";
"system.icon.keywords.building" = "gebouw, kantoor, magazijn, fabriek, faciliteit";
"system.icon.keywords.tent" = "kamperen, tent, buiten";
"system.icon.keywords.solar" = "zonne, zon, zonnepaneel";
"system.icon.keywords.battery" = "batterij, opslag, accu";
"system.icon.keywords.server" = "server, data, netwerk, rack";
"system.icon.keywords.computer" = "computer, elektronica, lab, tech";
"system.icon.keywords.gear" = "tandwiel, mechanica, machine, werkplaats";
"system.icon.keywords.tool" = "gereedschap, onderhoud, reparatie, werkplaats";
"system.icon.keywords.hammer" = "hamer, timmerwerk";
"system.icon.keywords.light" = "licht, verlichting, lamp";
"system.icon.keywords.bolt" = "stroom, kracht, elektrisch, spanning";
"system.icon.keywords.plug" = "stekker, aansluiting";
"system.icon.keywords.engine" = "motor, generator";
"system.icon.keywords.fuel" = "brandstof, diesel, benzine";
"system.icon.keywords.water" = "water, pomp, tank, reservoir";
"system.icon.keywords.heat" = "warmte, verwarming, kachel";
"system.icon.keywords.cold" = "koud, vries, koel";
"system.icon.keywords.climate" = "klimaat, hvac, temperatuur";
// Direct strings
"Systems" = "Systemen";
"System" = "Systeem";
"System View" = "Systeemweergave";
"System Name" = "Systeemnaam";
"Create System" = "Systeem maken";
"Create your first system" = "Maak je eerste systeem";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Geef je installatie een naam zodat **Cable by VoltPlan** lasten, bekabeling en aanbevelingen op één plek kan organiseren.";
"Add your first component" = "Voeg je eerste component toe";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Breng je systeem tot leven met componenten en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen.";
"Create Component" = "Component maken";
"Browse Library" = "Bibliotheek doorbladeren";
"Browse" = "Bladeren";
"Browse electrical components from VoltPlan" = "Blader door elektrische componenten van VoltPlan";
"Component Library" = "Componentenbibliotheek";
"Details coming soon" = "Details volgen binnenkort";
"Components" = "Componenten";
"FUSE" = "ZEKERING";
"WIRE" = "KABEL";
"Current" = "Stroom";
"Power" = "Vermogen";
"Voltage" = "Spanning";
"Length" = "Lengte";
"Length:" = "Lengte:";
"Wire Cross-Section:" = "Kabeldoorsnede:";
"Current Units" = "Huidige eenheden";
"Unit System" = "Eenheidssysteem";
"Units" = "Eenheden";
"Settings" = "Instellingen";
"Close" = "Sluiten";
"Cancel" = "Annuleren";
"Save" = "Opslaan";
"Retry" = "Opnieuw";
"Loading components" = "Componenten laden";
"Unable to load components" = "Componenten konden niet worden geladen";
"No components available" = "Geen componenten beschikbaar";
"No matches" = "Geen overeenkomsten";
"Check back soon for new loads from VoltPlan." = "Kom binnenkort terug voor nieuwe lasten van VoltPlan.";
"Try searching for a different name." = "Probeer een andere zoekterm.";
"Search components" = "Componenten zoeken";
"No loads saved in this system yet." = "Er zijn nog geen lasten in dit systeem opgeslagen.";
"Coming soon - manage your electrical systems and panels here." = "Binnenkort beschikbaar beheer hier je elektrische systemen en schakelkasten.";
"Load Library" = "Lastenbibliotheek";
"Safety Disclaimer" = "Veiligheidswaarschuwing";
"This application provides electrical calculations for educational and estimation purposes only." = "Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.";
"Important:" = "Belangrijk:";
"• Always consult qualified electricians for actual installations" = "• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien";
"• Follow all local electrical codes and regulations" = "• Volg alle lokale elektrische voorschriften en regels";
"• Electrical work should only be performed by licensed professionals" = "• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals";
"• These calculations may not account for all environmental factors" = "• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening";
"• The app developers assume no liability for electrical installations" = "• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties";
"Enter length in %@" = "Voer de lengte in in %@";
"Enter voltage in volts (V)" = "Voer de spanning in in volt (V)";
"Enter current in amperes (A)" = "Voer de stroom in in ampère (A)";
"Enter power in watts (W)" = "Voer het vermogen in in watt (W)";
"Edit Length" = "Lengte bewerken";
"Edit Voltage" = "Spanning bewerken";
"Edit Current" = "Stroom bewerken";
"Edit Power" = "Vermogen bewerken";
"Preview" = "Voorbeeld";
"Details" = "Details";
"Icon" = "Pictogram";
"Color" = "Kleur";
"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";
"battery.bank.banner.capacity" = "Capaciteitsafwijking gedetecteerd";
"battery.bank.empty.title" = "Nog geen batterijen";
"battery.bank.empty.subtitle" = "Tik op de plusknop om een batterij voor %@ te configureren.";
"battery.bank.status.dismiss" = "Begrepen";
"battery.bank.status.single.battery" = "Eén batterij";
"battery.bank.status.multiple.batteries" = "%d batterijen";
"battery.bank.status.voltage.title" = "Spanningsafwijking";
"battery.bank.status.voltage.message" = "%@ wijkt af van de basiswaarde %@ van de bank. Verschillende nominale spanningen zorgen voor ongelijk laden en kunnen aangesloten laders of omvormers beschadigen.";
"battery.bank.status.capacity.title" = "Capaciteitsafwijking";
"battery.bank.status.capacity.message" = "%@ gebruikt een andere capaciteit dan de dominante bankwaarde %@. Verschillende capaciteiten zorgen voor ongelijk ontladen en vroegtijdige slijtage.";
"battery.editor.title" = "Batterij configureren";
"battery.editor.cancel" = "Annuleren";
"battery.editor.save" = "Opslaan";
"battery.editor.field.name" = "Naam";
"battery.editor.placeholder.name" = "Huishoudbank";
"battery.editor.field.chemistry" = "Chemie";
"battery.editor.section.summary" = "Overzicht";
"battery.editor.slider.voltage" = "Nominale spanning";
"battery.editor.slider.capacity" = "Capaciteit";
"battery.editor.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

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>system.list.component.summary</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@component_count@ • %@</string>
<key>component_count</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>%d component</string>
<key>other</key>
<string>%d componenten</string>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,24 @@
{
"configurations" : [
{
"id" : "4CA9C9AC-F09F-48D8-9822-3CB6AF4C6D36",
"name" : "Configuration 1",
"options" : {
}
}
],
"defaultOptions" : {
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:Cable.xcodeproj",
"identifier" : "3E37F6542E93FB6F00836187",
"name" : "CableUITestsScreenshot"
}
}
],
"version" : 1
}

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

@@ -0,0 +1,105 @@
import Foundation
import Testing
@testable import Cable
struct ComponentLibraryItemTests {
@Test func localizedNameUsesExactLocaleMatch() async throws {
let item = ComponentLibraryItem(
id: "component-1",
name: "Anchor Winch",
translations: ["de_DE": "Ankerwinde"],
voltageIn: nil,
voltageOut: nil,
watt: nil,
iconURL: nil,
affiliateLinks: []
)
let german = Foundation.Locale(identifier: "de_DE")
#expect(item.localizedName(for: german) == "Ankerwinde")
}
@Test func localizedNameFallsBackToBaseName() async throws {
let item = ComponentLibraryItem(
id: "component-2",
name: "Anchor Winch",
translations: [:],
voltageIn: nil,
voltageOut: nil,
watt: nil,
iconURL: nil,
affiliateLinks: []
)
let french = Foundation.Locale(identifier: "fr_FR")
#expect(item.localizedName(for: french) == "Anchor Winch")
}
@Test func localizedNameDoesNotFallbackToSecondaryLanguages() async throws {
let item = ComponentLibraryItem(
id: "component-5",
name: "Anchor Winch",
translations: [
"es_ES": "Molinete",
"de_DE": "Ankerwinde"
],
voltageIn: nil,
voltageOut: nil,
watt: nil,
iconURL: nil,
affiliateLinks: []
)
let languages = ["fr-FR", "de-DE", "es-ES"]
#expect(item.localizedName(usingPreferredLanguages: languages) == nil)
}
@Test func localizedNameUsesLanguageOnlyMatch() async throws {
let item = ComponentLibraryItem(
id: "component-3",
name: "Anchor Winch",
translations: ["es": "Molinete"],
voltageIn: nil,
voltageOut: nil,
watt: nil,
iconURL: nil,
affiliateLinks: []
)
let spanishMexico = Foundation.Locale(identifier: "es_MX")
#expect(item.localizedName(for: spanishMexico) == "Molinete")
}
@Test func localizedNameFallsBackToMatchingLanguageFromRegionalEntry() async throws {
let item = ComponentLibraryItem(
id: "component-6",
name: "Anchor Winch",
translations: ["de_DE": "Ankerwinde"],
voltageIn: nil,
voltageOut: nil,
watt: nil,
iconURL: nil,
affiliateLinks: []
)
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
}
@Test func localizedNameHandlesHyphenatedKeys() async throws {
let item = ComponentLibraryItem(
id: "component-4",
name: "Anchor Winch",
translations: ["fr-FR": "Guindeau"],
voltageIn: nil,
voltageOut: nil,
watt: nil,
iconURL: nil,
affiliateLinks: []
)
let french = Foundation.Locale(identifier: "fr_FR")
#expect(item.localizedName(for: french) == "Guindeau")
}
}

View File

@@ -0,0 +1,603 @@
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 {
try super.setUpWithError()
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
//ensureDoNotDisturbEnabled()
//dismissSystemOverlays()
}
override func tearDownWithError() throws {
try super.tearDownWithError()
//dismissSystemOverlays()
}
@MainActor
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()
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() {
springboard.activate()
let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02))
let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30))
pullStart.press(forDuration: 0.1, thenDragTo: pullEnd)
let focusTile = springboard.otherElements["Focus"]
let focusButton = springboard.buttons["Focus"]
if focusTile.waitForExistence(timeout: 2) {
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"]
if dndButton.waitForExistence(timeout: 1) {
if !dndButton.isSelected {
dndButton.tap()
}
} else {
let dndCell = springboard.cells["Do Not Disturb"]
if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected {
dndCell.tap()
} else {
let dndLabel = springboard.staticTexts["Do Not Disturb"]
if dndLabel.waitForExistence(timeout: 1) {
dndLabel.tap()
}
}
}
let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
}
private func dismissSystemOverlays() {
let app = XCUIApplication()
let alertButtons = [
"OK", "Allow", "Later", "Not Now", "Close",
"Continue", "Remind Me Later", "Maybe Later",
]
if app.alerts.firstMatch.exists {
handleAlerts(in: app, buttons: alertButtons)
}
if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists {
handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"])
}
}
private func handleAlerts(in application: XCUIApplication, buttons: [String]) {
for buttonLabel in buttons {
let button = application.buttons[buttonLabel]
if button.waitForExistence(timeout: 0.5) {
button.tap()
return
}
}
let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch
if closeButton.exists {
closeButton.tap()
}
}
}

View File

@@ -0,0 +1,166 @@
//
// CableUITestsScreenshotLaunchTests.swift
// CableUITestsScreenshot
//
// Created by Stefan Lange-Hegermann on 06.10.25.
//
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,
lifetime: XCTAttachment.Lifetime = .keepAlways) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = lifetime
add(attachment)
}
override class var runsForEachTargetApplicationUIConfiguration: Bool {
false
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testOnboardingLoadsView() throws {
let app = launchApp(arguments: ["--uitest-reset-data"])
takeScreenshot(name: "01-OnboardingSystemsView")
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
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 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 = onboardingPrimaryButton(in: app)
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
createComponentButton.tap()
takeScreenshot(name: "03-LoadEditorView")
}
func testWithSampleData() throws {
let app = launchApp(arguments: ["--uitest-sample-data"])
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")
let rowButton = firstSystemCell.buttons.firstMatch
if rowButton.waitForExistence(timeout: 2) {
rowButton.tap()
} else {
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")
let bomButton = app.buttons["system-bom-button"]
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")
}
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)
}
}

3
Gemfile Normal file
View File

@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"

229
Gemfile.lock Normal file
View File

@@ -0,0 +1,229 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.7)
base64
nkf
rexml
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1168.0)
aws-sdk-core (3.233.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.113.0)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.199.1)
aws-sdk-core (~> 3, >= 3.231.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
bigdecimal (3.2.3)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (0.112.0)
faraday (1.10.4)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.7)
faraday (>= 0.8.0)
http-cookie (~> 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.0)
fastlane (2.228.0)
CFPropertyList (>= 2.3, < 4.0.0)
addressable (>= 2.8, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.0)
babosa (>= 1.0.3, < 2.0.0)
bundler (>= 1.12.0, < 3.0.0)
colored (~> 1.2)
commander (~> 4.6)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 1.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.0.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.1.0, < 3)
mini_magick (>= 4.9.4, < 5.0.0)
multipart-post (>= 2.0.0, < 3.0.0)
naturally (~> 2.2)
optparse (>= 0.1.1, < 1.0.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.0.0)
sysrandom (~> 1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.54.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-core (0.11.3)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
httpclient (>= 2.8.1, < 3.a)
mini_mime (~> 1.0)
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
rexml
google-apis-iamcredentials_v1 (0.17.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-playcustomapp_v1 (0.13.0)
google-apis-core (>= 0.11.0, < 2.a)
google-apis-storage_v1 (0.31.0)
google-apis-core (>= 0.11.0, < 2.a)
google-cloud-core (1.8.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.5.0)
google-cloud-storage (1.47.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-iamcredentials_v1 (~> 0.1)
google-apis-storage_v1 (~> 0.31.0)
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
googleauth (1.8.1)
faraday (>= 0.17.3, < 3.a)
jwt (>= 1.4, < 3.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.15.0)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.17.0)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.2.0)
optparse (0.6.0)
os (1.1.4)
plist (3.7.2)
public_suffix (6.0.2)
rake (13.3.0)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
simctl (1.6.10)
CFPropertyList
naturally
sysrandom (1.0.5)
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.27.0)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
arm64-darwin-24
ruby
DEPENDENCIES
fastlane
BUNDLED WITH
2.7.2

Some files were not shown because too many files have changed in this diff Show More