Compare commits
42 Commits
0a2789dc44
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8da6987f32 | ||
|
|
b11d627fdb | ||
|
|
ced06f9eb6 | ||
|
|
5fcc33529a | ||
|
|
97a9d3903c | ||
|
|
45a462295d | ||
|
|
10dc0e4fa9 | ||
|
|
8868368392 | ||
|
|
a2314585ea | ||
|
|
46664625b4 | ||
|
|
0989c68aa7 | ||
|
|
51d85cc352 | ||
|
|
cd8a043c5c | ||
|
|
0720529821 | ||
|
|
6258a6a66f | ||
|
|
802b111aa7 | ||
|
|
c7ff9322ef | ||
|
|
d081a79b59 | ||
|
|
9f8d8e5149 | ||
|
|
858bf2a305 | ||
|
|
f171c3d6b2 | ||
|
|
a6f2f8fc91 | ||
|
|
1fef290abf | ||
|
|
df315ea7d8 | ||
|
|
2a2c48e89f | ||
|
|
4827ea4cdb | ||
|
|
28ad6dd10c | ||
|
|
3c366dc454 | ||
|
|
420a6ea014 | ||
|
|
dd13178f0e | ||
|
|
cfcaab149f | ||
|
|
5d7c886ee8 | ||
|
|
296cf63176 | ||
|
|
16fd491af5 | ||
|
|
7c5c4dff5c | ||
|
|
cb628277fb | ||
|
|
03aa843f26 | ||
|
|
2f0cebceed | ||
|
|
ab5e3e14ac | ||
|
|
a35ad49a58 | ||
|
|
0842815133 | ||
|
|
5fb8997ab9 |
2
.bundle/config
Normal file
@@ -0,0 +1,2 @@
|
||||
---
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.DS_*
|
||||
fastlane/screenshots
|
||||
xcshareddata
|
||||
Vendor
|
||||
Shots/Framed
|
||||
Shots/Screenshots
|
||||
*.xcresult
|
||||
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
||||
3.2.4
|
||||
|
Before Width: | Height: | Size: 146 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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>
|
||||
|
||||
7
Cable.xcworkspace/contents.xcworkspacedata
generated
Normal 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
@@ -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
|
||||
}
|
||||
}
|
||||
BIN
Cable/AppIcon copy.icon/Assets/body 3.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
Cable/AppIcon copy.icon/Assets/fuse-top.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Cable/AppIcon copy.icon/Assets/legs 2.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
295
Cable/AppIcon copy.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
Cable/AppIcon.icon/Assets/body 3.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
Cable/AppIcon.icon/Assets/flash.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
Cable/AppIcon.icon/Assets/fuse-top.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Cable/AppIcon.icon/Assets/legs 2.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
181
Cable/AppIcon.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon1024_opaque.png",
|
||||
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -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"
|
||||
}
|
||||
|
||||
BIN
Cable/Assets.xcassets/PoweredByVoltplan.imageset/voltplan-logo-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
52
Cable/Assets.xcassets/battery-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "battery-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "battery-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/battery-onboarding.imageset/battery-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
Cable/Assets.xcassets/battery-onboarding.imageset/battery-light.png
vendored
Normal file
|
After Width: | Height: | Size: 75 KiB |
52
Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/boat-onboarding.imageset/boat-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
Cable/Assets.xcassets/boat-onboarding.imageset/boat-light.png
vendored
Normal file
|
After Width: | Height: | Size: 145 KiB |
52
Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-light.png
vendored
Normal file
|
After Width: | Height: | Size: 108 KiB |
52
Cable/Assets.xcassets/charger-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/charger-onboarding.imageset/charger-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
Cable/Assets.xcassets/charger-onboarding.imageset/charger-light.png
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
52
Cable/Assets.xcassets/coffee-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/coffee-onboarding.imageset/coffee-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
Cable/Assets.xcassets/coffee-onboarding.imageset/coffee-light.png
vendored
Normal file
|
After Width: | Height: | Size: 81 KiB |
52
Cable/Assets.xcassets/router-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/router-onboarding.imageset/router-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
Cable/Assets.xcassets/router-onboarding.imageset/router-light.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
52
Cable/Assets.xcassets/van-onboarding.imageset/Contents.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/van-onboarding.imageset/bus-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
Cable/Assets.xcassets/van-onboarding.imageset/bus-light.png
vendored
Normal file
|
After Width: | Height: | Size: 122 KiB |
326
Cable/Base.lproj/Localizable.strings
Normal 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";
|
||||
22
Cable/Base.lproj/Localizable.stringsdict
Normal 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>
|
||||
639
Cable/Batteries/BatteriesView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
174
Cable/Batteries/BatteryConfiguration.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
1318
Cable/Batteries/BatteryEditorView.swift
Normal 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
67
Cable/Chargers/ChargerConfiguration.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
struct ChargerConfiguration: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var inputVoltage: Double
|
||||
var outputVoltage: Double
|
||||
var maxCurrentAmps: Double
|
||||
var maxPowerWatts: Double
|
||||
var iconName: String
|
||||
var colorName: String
|
||||
var system: ElectricalSystem
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
inputVoltage: Double = 230.0,
|
||||
outputVoltage: Double = 14.2,
|
||||
maxCurrentAmps: Double = 30.0,
|
||||
maxPowerWatts: Double = 0.0,
|
||||
iconName: String = "bolt.fill",
|
||||
colorName: String = "orange",
|
||||
system: ElectricalSystem
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.inputVoltage = inputVoltage
|
||||
self.outputVoltage = outputVoltage
|
||||
self.maxCurrentAmps = maxCurrentAmps
|
||||
self.maxPowerWatts = maxPowerWatts
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
}
|
||||
|
||||
init(savedCharger: SavedCharger, system: ElectricalSystem) {
|
||||
self.id = savedCharger.id
|
||||
self.name = savedCharger.name
|
||||
self.inputVoltage = savedCharger.inputVoltage
|
||||
self.outputVoltage = savedCharger.outputVoltage
|
||||
self.maxCurrentAmps = savedCharger.maxCurrentAmps
|
||||
self.maxPowerWatts = savedCharger.maxPowerWatts
|
||||
self.iconName = savedCharger.iconName
|
||||
self.colorName = savedCharger.colorName
|
||||
self.system = system
|
||||
}
|
||||
|
||||
var effectivePowerWatts: Double {
|
||||
if maxPowerWatts > 0 {
|
||||
return maxPowerWatts
|
||||
}
|
||||
return outputVoltage * maxCurrentAmps
|
||||
}
|
||||
|
||||
func apply(to savedCharger: SavedCharger) {
|
||||
savedCharger.name = name
|
||||
savedCharger.inputVoltage = inputVoltage
|
||||
savedCharger.outputVoltage = outputVoltage
|
||||
savedCharger.maxCurrentAmps = maxCurrentAmps
|
||||
savedCharger.maxPowerWatts = maxPowerWatts
|
||||
savedCharger.iconName = iconName
|
||||
savedCharger.colorName = colorName
|
||||
savedCharger.system = system
|
||||
savedCharger.timestamp = Date()
|
||||
}
|
||||
}
|
||||
952
Cable/Chargers/ChargerEditorView.swift
Normal file
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
397
Cable/Chargers/ChargersView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
62
Cable/Chargers/SavedCharger.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Cable/Loads/CableCalculator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
1842
Cable/Loads/CalculatorView.swift
Normal 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
|
||||
138
Cable/Loads/ElectricalCalculations.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Cable/Loads/LoadConfigurationStatus.swift
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
151
Cable/Loads/LoadIconView.swift
Normal 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
165
Cable/Loads/OnboardingInfoView.swift
Normal 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"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
32
Cable/OnboardingCarouselView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
1613
Cable/Overview/SystemOverviewView.swift
Normal file
546
Cable/Paywall/CableProPaywallView.swift
Normal 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
@@ -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
@@ -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)
|
||||
}
|
||||
11
Cable/Shared/ShareSheet.swift
Normal 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) {}
|
||||
}
|
||||
48
Cable/StatsHeaderContainer.swift
Normal 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
@@ -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
|
||||
}
|
||||
}
|
||||
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal 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]
|
||||
}
|
||||
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
1020
Cable/Systems/SystemBillOfMaterialsView.swift
Normal file
270
Cable/Systems/SystemComponentsPersistence.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
133
Cable/Systems/SystemsOnboardingView.swift
Normal 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 }
|
||||
}
|
||||
598
Cable/Systems/SystemsView.swift
Normal 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())
|
||||
}
|
||||
224
Cable/UITestSampleData.swift
Normal 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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
391
Cable/de.lproj/Localizable.strings
Normal 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";
|
||||
22
Cable/de.lproj/Localizable.stringsdict
Normal 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>
|
||||
353
Cable/es.lproj/Localizable.strings
Normal 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";
|
||||
22
Cable/es.lproj/Localizable.stringsdict
Normal 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>
|
||||
353
Cable/fr.lproj/Localizable.strings
Normal 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 l’instant.";
|
||||
"bom.export.pdf.button" = "Exporter en PDF";
|
||||
"bom.export.pdf.error.title" = "Échec de l’export";
|
||||
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant l’export.";
|
||||
"bom.pdf.header.title" = "Liste de matériaux du système";
|
||||
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||
"bom.pdf.header.inline" = "Système d’unité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 l’autonomie.";
|
||||
"overview.bom.title" = "Liste de matériel";
|
||||
"overview.bom.subtitle" = "Touchez pour consulter les composants";
|
||||
"overview.bom.unavailable" = "Ajoutez des charges pour générer des composants.";
|
||||
"overview.bom.placeholder.short" = "Ajouter des charges";
|
||||
"overview.chargetime.title" = "Temps de charge estimé";
|
||||
"overview.chargetime.subtitle" = "Au débit de charge combiné";
|
||||
"overview.chargetime.unavailable" = "Ajoutez des chargeurs et de la capacité batterie pour estimer.";
|
||||
"overview.chargetime.placeholder.short" = "Ajouter des chargeurs";
|
||||
"overview.goal.prefix" = "Objectif";
|
||||
"overview.goal.label" = "Objectif %@";
|
||||
"overview.goal.clear" = "Supprimer l'objectif";
|
||||
"overview.goal.cancel" = "Annuler";
|
||||
"overview.goal.save" = "Enregistrer";
|
||||
"overview.runtime.goal.title" = "Objectif d'autonomie";
|
||||
"overview.chargetime.goal.title" = "Objectif de recharge";
|
||||
"overview.runtime.placeholder.short" = "Ajouter capacité";
|
||||
"battery.bank.warning.voltage.short" = "Tension";
|
||||
"battery.bank.warning.capacity.short" = "Capacité";
|
||||
|
||||
"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 d’ensemble des chargeurs";
|
||||
"overview.chargers.empty.title" = "Aucun chargeur configuré pour l’instant";
|
||||
"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 l’utilisation";
|
||||
"generic.ok" = "OK";
|
||||
22
Cable/fr.lproj/Localizable.stringsdict
Normal 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>
|
||||
353
Cable/nl.lproj/Localizable.strings
Normal 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";
|
||||
22
Cable/nl.lproj/Localizable.stringsdict
Normal 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>
|
||||
24
CableScreenshots.xctestplan
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
105
CableTests/ComponentLibraryItemTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
603
CableUITestsScreenshot/CableUITestsScreenshot.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
166
CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
229
Gemfile.lock
Normal 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
|
||||