Compare commits
32 Commits
296cf63176
...
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 |
2
.bundle/config
Normal file
@@ -0,0 +1,2 @@
|
||||
---
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
4
.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
.DS_*
|
||||
fastlane/screenshots
|
||||
xcshareddata
|
||||
Vendor
|
||||
Shots/Framed
|
||||
Shots/Screenshots
|
||||
*.xcresult
|
||||
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
||||
3.2.4
|
||||
@@ -45,11 +45,21 @@
|
||||
);
|
||||
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
||||
};
|
||||
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
CableUITestsScreenshotLaunchTests.swift,
|
||||
);
|
||||
target = 3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */,
|
||||
);
|
||||
path = CableUITestsScreenshot;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -113,6 +123,7 @@
|
||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||
57738E9B07763CFA62681EEE /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -127,6 +138,13 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
57738E9B07763CFA62681EEE /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -147,8 +165,6 @@
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||
);
|
||||
name = CableUITestsScreenshot;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CableUITestsScreenshot;
|
||||
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
@@ -169,8 +185,6 @@
|
||||
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
||||
);
|
||||
name = Cable;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = Cable;
|
||||
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -192,8 +206,6 @@
|
||||
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
||||
);
|
||||
name = CableTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CableTests;
|
||||
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
@@ -215,8 +227,6 @@
|
||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||
);
|
||||
name = CableUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CableUITests;
|
||||
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
@@ -403,11 +413,13 @@
|
||||
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 = 2;
|
||||
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;
|
||||
@@ -421,9 +433,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2;
|
||||
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";
|
||||
@@ -436,11 +449,13 @@
|
||||
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 = 2;
|
||||
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;
|
||||
@@ -454,9 +469,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.2;
|
||||
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";
|
||||
@@ -516,7 +532,7 @@
|
||||
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;
|
||||
@@ -575,7 +591,7 @@
|
||||
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;
|
||||
|
||||
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 |
|
Before Width: | Height: | Size: 71 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 |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,63 +1,171 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.50588,0.79216,0.56471,1.00000"
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.94971,1.00000,0.96298,1.00000",
|
||||
"extended-gray:1.00000,1.00000"
|
||||
]
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blur-material" : 0.9,
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"glass" : false,
|
||||
"hidden" : false,
|
||||
"image-name" : "voltplan-lines.png",
|
||||
"name" : "voltplan-lines",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
80.8265625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:1.00000,1.00000,1.00000,1.00000",
|
||||
"srgb:1.00000,1.00000,1.00000,0.63382"
|
||||
]
|
||||
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
|
||||
},
|
||||
"image-name" : "voltplan-logo 2 2.png",
|
||||
"name" : "voltplan-logo 2 2",
|
||||
"glass" : true,
|
||||
"hidden" : true,
|
||||
"image-name" : "flash.png",
|
||||
"name" : "flash",
|
||||
"opacity" : 1,
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"scale" : 0.8,
|
||||
"translation-in-points" : [
|
||||
-3,
|
||||
77.55625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:1.00000,1.00000,1.00000,1.00000",
|
||||
"srgb:1.00000,1.00000,1.00000,0.50000"
|
||||
]
|
||||
},
|
||||
"image-name" : "box-2.png",
|
||||
"name" : "Layer",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-91.0703125
|
||||
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
|
||||
|
||||
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 |
@@ -2,6 +2,77 @@
|
||||
"affiliate.description.with_link" = "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.";
|
||||
"affiliate.description.without_link" = "Tapping above shows a full bill of materials with shopping searches to help you source parts.";
|
||||
"affiliate.disclaimer" = "Purchases through affiliate links may support VoltPlan.";
|
||||
"battery.bank.badge.capacity" = "Capacity";
|
||||
"battery.bank.badge.energy" = "Energy";
|
||||
"battery.bank.badge.voltage" = "Voltage";
|
||||
"battery.bank.banner.capacity" = "Capacity mismatch detected";
|
||||
"battery.bank.banner.voltage" = "Voltage mismatch detected";
|
||||
"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@.";
|
||||
"battery.bank.empty.title" = "No Batteries Yet";
|
||||
"battery.bank.header.title" = "Battery Bank";
|
||||
"battery.bank.metric.capacity" = "Capacity";
|
||||
"battery.bank.metric.count" = "Batteries";
|
||||
"battery.bank.metric.energy" = "Energy";
|
||||
"battery.bank.metric.usable_capacity" = "Usable Capacity";
|
||||
"battery.bank.metric.usable_energy" = "Usable Energy";
|
||||
"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.";
|
||||
"battery.bank.status.capacity.title" = "Capacity mismatch";
|
||||
"battery.bank.status.dismiss" = "Got it";
|
||||
"battery.bank.status.multiple.batteries" = "%d batteries";
|
||||
"battery.bank.status.single.battery" = "One battery";
|
||||
"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.";
|
||||
"battery.bank.status.voltage.title" = "Voltage mismatch";
|
||||
"battery.bank.warning.capacity.short" = "Capacity";
|
||||
"battery.bank.warning.voltage.short" = "Voltage";
|
||||
"battery.editor.advanced.usable_capacity.footer_default" = "Defaults to %@ based on chemistry.";
|
||||
"battery.editor.advanced.usable_capacity.footer_override" = "Override active. Chemistry default remains %@.";
|
||||
"battery.editor.advanced.charge_voltage.helper" = "Set the maximum recommended charging voltage.";
|
||||
"battery.editor.advanced.cutoff_voltage.helper" = "Set the minimum safe discharge voltage.";
|
||||
"battery.editor.advanced.temperature_range.helper" = "Define the recommended operating temperature range.";
|
||||
"battery.editor.alert.charge_voltage.message" = "Enter charge voltage in volts (V)";
|
||||
"battery.editor.alert.charge_voltage.placeholder" = "Charge Voltage";
|
||||
"battery.editor.alert.charge_voltage.title" = "Edit Charge Voltage";
|
||||
"battery.editor.alert.cutoff_voltage.message" = "Enter cut-off voltage in volts (V)";
|
||||
"battery.editor.alert.cutoff_voltage.placeholder" = "Cut-off Voltage";
|
||||
"battery.editor.alert.cutoff_voltage.title" = "Edit Cut-off Voltage";
|
||||
"battery.editor.alert.maximum_temperature.message" = "Enter maximum temperature in degrees Celsius (\u00B0C)";
|
||||
"battery.editor.alert.maximum_temperature.placeholder" = "Maximum Temperature (\u00B0C)";
|
||||
"battery.editor.alert.maximum_temperature.title" = "Edit Maximum Temperature";
|
||||
"battery.editor.alert.minimum_temperature.message" = "Enter minimum temperature in degrees Celsius (\u00B0C)";
|
||||
"battery.editor.alert.minimum_temperature.placeholder" = "Minimum Temperature (\u00B0C)";
|
||||
"battery.editor.alert.minimum_temperature.title" = "Edit Minimum Temperature";
|
||||
"battery.editor.alert.cancel" = "Cancel";
|
||||
"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)";
|
||||
"battery.editor.alert.capacity.placeholder" = "Capacity";
|
||||
"battery.editor.alert.capacity.title" = "Edit Capacity";
|
||||
"battery.editor.alert.save" = "Save";
|
||||
"battery.editor.alert.usable_capacity.message" = "Enter usable capacity percentage (%)";
|
||||
"battery.editor.alert.usable_capacity.placeholder" = "Usable Capacity (%)";
|
||||
"battery.editor.alert.usable_capacity.title" = "Edit Usable Capacity";
|
||||
"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)";
|
||||
"battery.editor.alert.voltage.placeholder" = "Voltage";
|
||||
"battery.editor.alert.voltage.title" = "Edit Nominal Voltage";
|
||||
"battery.editor.button.reset_default" = "Reset";
|
||||
"battery.editor.cancel" = "Cancel";
|
||||
"battery.editor.default_name" = "New Battery";
|
||||
"battery.editor.field.chemistry" = "Chemistry";
|
||||
"battery.editor.field.name" = "Name";
|
||||
"battery.editor.placeholder.name" = "House Bank";
|
||||
"battery.editor.save" = "Save";
|
||||
"battery.editor.section.advanced" = "Advanced";
|
||||
"battery.editor.section.summary" = "Summary";
|
||||
"battery.editor.slider.capacity" = "Capacity";
|
||||
"battery.editor.slider.charge_voltage" = "Charge Voltage";
|
||||
"battery.editor.slider.cutoff_voltage" = "Cut-off Voltage";
|
||||
"battery.editor.slider.temperature_range" = "Temperature Range";
|
||||
"battery.editor.slider.temperature_range.max" = "Maximum";
|
||||
"battery.editor.slider.temperature_range.min" = "Minimum";
|
||||
"battery.editor.slider.usable_capacity" = "Usable Capacity (%)";
|
||||
"battery.editor.slider.voltage" = "Nominal Voltage";
|
||||
"battery.editor.title" = "Battery Setup";
|
||||
"battery.onboarding.subtitle" = "Track your bank's capacity and chemistry to keep runtime expectations in check.";
|
||||
"battery.onboarding.title" = "Add your first battery";
|
||||
"battery.overview.empty.create" = "Add Battery";
|
||||
"bom.accessibility.mark.complete" = "Mark %@ complete";
|
||||
"bom.accessibility.mark.incomplete" = "Mark %@ incomplete";
|
||||
"bom.fuse.detail" = "Inline holder and %dA fuse";
|
||||
@@ -13,11 +84,93 @@
|
||||
"bom.navigation.title.system" = "BOM – %@";
|
||||
"bom.size.unknown" = "Size TBD";
|
||||
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
||||
"bom.empty.message" = "No components saved in this system yet.";
|
||||
"bom.export.pdf.button" = "Export PDF";
|
||||
"bom.export.pdf.error.title" = "Export Failed";
|
||||
"bom.export.pdf.error.empty" = "Add at least one component before exporting.";
|
||||
"bom.pdf.header.title" = "System Bill of Materials";
|
||||
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||
"bom.pdf.header.inline" = "Unit System: %@";
|
||||
"bom.pdf.placeholder.empty" = "No components available.";
|
||||
"bom.pdf.page.number" = "Page %d";
|
||||
"bom.category.components.title" = "Components & Chargers";
|
||||
"bom.category.components.subtitle" = "Primary devices, controllers, and charging gear.";
|
||||
"bom.category.batteries.title" = "Batteries";
|
||||
"bom.category.batteries.subtitle" = "House banks and storage.";
|
||||
"bom.category.cables.title" = "Cables";
|
||||
"bom.category.cables.subtitle" = "Sized power runs for every circuit.";
|
||||
"bom.category.fuses.title" = "Fuses";
|
||||
"bom.category.fuses.subtitle" = "Circuit protection and holders.";
|
||||
"bom.category.accessories.title" = "Accessories";
|
||||
"bom.category.accessories.subtitle" = "Fuses, lugs, and supporting hardware.";
|
||||
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||
"bom.quantity.count.badge" = "%d×";
|
||||
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
|
||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.single.badge" = "1× • %@";
|
||||
"cable.pro.privacy.label" = "Privacy";
|
||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
||||
"cable.pro.terms.label" = "Terms";
|
||||
"cable.pro.terms.url" = "https://voltplan.app/terms";
|
||||
"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power.";
|
||||
"calculator.advanced.duty_cycle.title" = "Duty Cycle";
|
||||
"calculator.advanced.section.title" = "Advanced Settings";
|
||||
"calculator.advanced.usage_hours.helper" = "Hours per day the load is turned on.";
|
||||
"calculator.advanced.usage_hours.title" = "Daily On-Time";
|
||||
"calculator.advanced.usage_hours.unit" = "h/day";
|
||||
"calculator.alert.duty_cycle.message" = "Enter duty cycle as a percentage (0-100%).";
|
||||
"calculator.alert.duty_cycle.placeholder" = "Duty Cycle";
|
||||
"calculator.alert.duty_cycle.title" = "Edit Duty Cycle";
|
||||
"calculator.alert.usage_hours.message" = "Enter the number of hours per day the load is active.";
|
||||
"calculator.alert.usage_hours.placeholder" = "Daily On-Time";
|
||||
"calculator.alert.usage_hours.title" = "Edit Daily On-Time";
|
||||
"charger.default.new" = "New Charger";
|
||||
"charger.editor.alert.cancel" = "Cancel";
|
||||
"charger.editor.alert.current.message" = "Enter current in amps (A)";
|
||||
"charger.editor.alert.current.title" = "Edit Charge Current";
|
||||
"charger.editor.alert.input_voltage.title" = "Edit Input Voltage";
|
||||
"charger.editor.alert.output_voltage.title" = "Edit Output Voltage";
|
||||
"charger.editor.alert.power.message" = "Enter power in watts (W)";
|
||||
"charger.editor.alert.power.placeholder" = "Power";
|
||||
"charger.editor.alert.power.title" = "Edit Charge Power";
|
||||
"charger.editor.alert.save" = "Save";
|
||||
"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)";
|
||||
"charger.editor.appearance.accessibility" = "Edit charger appearance";
|
||||
"charger.editor.appearance.subtitle" = "Customize how this charger shows up";
|
||||
"charger.editor.appearance.title" = "Charger Appearance";
|
||||
"charger.editor.default_name" = "New Charger";
|
||||
"charger.editor.field.current" = "Charge Current";
|
||||
"charger.editor.field.input_voltage" = "Input Voltage";
|
||||
"charger.editor.field.name" = "Name";
|
||||
"charger.editor.field.output_voltage" = "Output Voltage";
|
||||
"charger.editor.field.power" = "Charge Power";
|
||||
"charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current.";
|
||||
"charger.editor.placeholder.name" = "Workshop Charger";
|
||||
"charger.editor.section.electrical" = "Electrical";
|
||||
"charger.editor.section.power" = "Charge Output";
|
||||
"charger.editor.title" = "Charger";
|
||||
"chargers.badge.current" = "Current";
|
||||
"chargers.badge.input" = "Input";
|
||||
"chargers.badge.output" = "Output";
|
||||
"chargers.badge.power" = "Power";
|
||||
"chargers.onboarding.primary" = "Create Charger";
|
||||
"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity.";
|
||||
"chargers.onboarding.title" = "Add your chargers";
|
||||
"chargers.subtitle" = "Charger components will be available soon.";
|
||||
"chargers.summary.metric.count" = "Chargers";
|
||||
"chargers.summary.metric.current" = "Charge Rate";
|
||||
"chargers.summary.metric.output" = "Output Voltage";
|
||||
"chargers.summary.metric.power" = "Charge Power";
|
||||
"chargers.summary.title" = "Charging Overview";
|
||||
"chargers.title" = "Chargers for %@";
|
||||
"component.fallback.name" = "Component";
|
||||
"default.load.library" = "Library Load";
|
||||
"default.load.name" = "My Load";
|
||||
"default.load.unnamed" = "Unnamed Load";
|
||||
"default.load.new" = "New Load";
|
||||
"default.load.unnamed" = "Unnamed Load";
|
||||
"default.system.name" = "My System";
|
||||
"default.system.new" = "New System";
|
||||
"editor.load.name_field" = "Load name";
|
||||
@@ -26,12 +179,148 @@
|
||||
"editor.system.location.optional" = "Location (optional)";
|
||||
"editor.system.name_field" = "System name";
|
||||
"editor.system.title" = "Edit System";
|
||||
"loads.library.button" = "Library";
|
||||
"loads.metric.cable" = "Cable";
|
||||
"loads.metric.fuse" = "Fuse";
|
||||
"loads.metric.length" = "Length";
|
||||
"loads.onboarding.subtitle" = "Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.";
|
||||
"loads.onboarding.title" = "Add your first component";
|
||||
"loads.overview.empty.create" = "Add Load";
|
||||
"loads.overview.empty.library" = "Browse Library";
|
||||
"loads.overview.empty.message" = "Start by adding a load to see system insights.";
|
||||
"loads.overview.header.title" = "Load Overview";
|
||||
"loads.overview.metric.count" = "Loads";
|
||||
"loads.overview.metric.current" = "Total Current";
|
||||
"loads.overview.metric.power" = "Total Power";
|
||||
"loads.overview.status.missing_details.banner" = "Finish configuring your loads";
|
||||
"loads.overview.status.missing_details.message" = "Enter cable length and wire size for %d %@ to see accurate recommendations.";
|
||||
"loads.overview.status.missing_details.plural" = "loads";
|
||||
"loads.overview.status.missing_details.singular" = "load";
|
||||
"loads.overview.status.missing_details.title" = "Missing load details";
|
||||
"overview.chargers.empty.create" = "Add Charger";
|
||||
"overview.chargers.empty.subtitle" = "Add shore power, DC-DC, or solar chargers to understand your charging capacity.";
|
||||
"overview.chargers.empty.title" = "No chargers configured yet";
|
||||
"overview.chargers.header.title" = "Charger Overview";
|
||||
"overview.loads.empty.subtitle" = "Add components to get cable sizing and fuse recommendations tailored to this system.";
|
||||
"overview.loads.empty.title" = "No loads configured yet";
|
||||
"overview.runtime.subtitle" = "At maximum load draw";
|
||||
"overview.runtime.title" = "Estimated runtime";
|
||||
"overview.runtime.unavailable" = "Add battery capacity and load power to estimate runtime.";
|
||||
"overview.system.header.title" = "System Overview";
|
||||
"overview.bom.title" = "Bill of Materials";
|
||||
"overview.bom.subtitle" = "Tap to review components";
|
||||
"overview.bom.unavailable" = "Add loads to generate components.";
|
||||
"overview.bom.placeholder.short" = "Add loads";
|
||||
"overview.chargetime.title" = "Estimated charge time";
|
||||
"overview.chargetime.subtitle" = "At combined charge rate";
|
||||
"overview.chargetime.unavailable" = "Add chargers and battery capacity to estimate.";
|
||||
"overview.chargetime.placeholder.short" = "Add chargers";
|
||||
"overview.goal.prefix" = "Goal";
|
||||
"overview.goal.label" = "Goal %@";
|
||||
"overview.goal.clear" = "Remove Goal";
|
||||
"overview.goal.cancel" = "Cancel";
|
||||
"overview.goal.save" = "Save";
|
||||
"overview.runtime.goal.title" = "Runtime Goal";
|
||||
"overview.runtime.placeholder.short" = "Add capacity";
|
||||
"overview.chargetime.goal.title" = "Charge Goal";
|
||||
"sample.battery.rv.name" = "LiFePO4 house bank";
|
||||
"sample.battery.workshop.name" = "Workbench backup battery";
|
||||
"sample.charger.dcdc.name" = "DC-DC charger";
|
||||
"sample.charger.shore.name" = "Shore power charger";
|
||||
"sample.charger.workbench.name" = "Workbench charger";
|
||||
"sample.load.charger.name" = "Tool charger";
|
||||
"sample.load.compressor.name" = "Air compressor";
|
||||
"sample.load.fridge.name" = "Compressor fridge";
|
||||
"sample.load.lighting.name" = "LED strip lighting";
|
||||
"sample.system.rv.location" = "12V living circuit";
|
||||
"sample.system.rv.name" = "Adventure Van";
|
||||
"sample.system.workshop.location" = "Tool corner";
|
||||
"sample.system.workshop.name" = "Workshop Bench";
|
||||
"slider.button.ampere" = "Ampere";
|
||||
"slider.button.watt" = "Watt";
|
||||
"slider.current.title" = "Current";
|
||||
"slider.length.title" = "Cable Length (%@)";
|
||||
"slider.power.title" = "Power";
|
||||
"slider.voltage.title" = "Voltage";
|
||||
"system.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";
|
||||
|
||||
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
@@ -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 = String(localized: "default.load.name", comment: "Default placeholder name for a load")
|
||||
|
||||
var calculatedPower: Double {
|
||||
voltage * current
|
||||
}
|
||||
|
||||
var calculatedCurrent: Double {
|
||||
voltage > 0 ? power / voltage : 0
|
||||
}
|
||||
|
||||
func updateFromCurrent() {
|
||||
power = voltage * current
|
||||
}
|
||||
|
||||
func updateFromPower() {
|
||||
current = voltage > 0 ? power / voltage : 0
|
||||
}
|
||||
|
||||
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
|
||||
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048 // ft to m
|
||||
// Simplified calculation: minimum cross-section based on current and voltage drop
|
||||
let maxVoltageDrop = voltage * 0.05 // 5% voltage drop limit
|
||||
let resistivity = 0.017 // Copper resistivity at 20°C (Ω⋅mm²/m)
|
||||
let calculatedMinCrossSection = (2 * current * lengthInMeters * resistivity) / maxVoltageDrop
|
||||
|
||||
if unitSystem == .imperial {
|
||||
// Standard AWG wire sizes
|
||||
let standardAWG = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
|
||||
let awgCrossSections = [0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0]
|
||||
|
||||
// Find the smallest AWG that meets the requirement
|
||||
for (index, crossSection) in awgCrossSections.enumerated() {
|
||||
if crossSection >= calculatedMinCrossSection {
|
||||
return Double(standardAWG[index])
|
||||
}
|
||||
}
|
||||
return Double(standardAWG.last!) // Largest available
|
||||
} else {
|
||||
// Standard metric cable cross-sections in mm²
|
||||
let standardSizes = [0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0]
|
||||
|
||||
// Find the smallest standard size that meets the requirement
|
||||
return standardSizes.first { $0 >= max(0.75, calculatedMinCrossSection) } ?? standardSizes.last!
|
||||
}
|
||||
}
|
||||
|
||||
func crossSection(for unitSystem: UnitSystem) -> Double {
|
||||
recommendedCrossSection(for: unitSystem)
|
||||
}
|
||||
|
||||
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
||||
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048
|
||||
let crossSectionMM2 = unitSystem == .metric ? crossSection(for: unitSystem) : crossSectionFromAWG(crossSection(for: unitSystem))
|
||||
let resistivity = 0.017
|
||||
let effectiveCurrent = current // Always use the current property which gets updated
|
||||
return (2 * effectiveCurrent * lengthInMeters * resistivity) / crossSectionMM2
|
||||
}
|
||||
|
||||
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
||||
(voltageDrop(for: unitSystem) / voltage) * 100
|
||||
}
|
||||
|
||||
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
||||
let effectiveCurrent = current
|
||||
return effectiveCurrent * voltageDrop(for: unitSystem)
|
||||
}
|
||||
|
||||
var recommendedFuse: Int {
|
||||
let targetFuse = current * 1.25 // 125% of load current for safety
|
||||
|
||||
// Common fuse values in amperes
|
||||
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
||||
|
||||
// Find the smallest standard fuse that's >= target
|
||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
||||
}
|
||||
|
||||
// AWG conversion helper for voltage drop calculations
|
||||
private func crossSectionFromAWG(_ awg: Double) -> Double {
|
||||
let awgSizes = [20: 0.519, 18: 0.823, 16: 1.31, 14: 2.08, 12: 3.31, 10: 5.26, 8: 8.37, 6: 13.3, 4: 21.2, 2: 33.6, 1: 42.4, 0: 53.5]
|
||||
|
||||
// Handle 00, 000, 0000 AWG (represented as negative values)
|
||||
if awg == 00 { return 67.4 }
|
||||
if awg == 000 { return 85.0 }
|
||||
if awg == 0000 { return 107.0 }
|
||||
|
||||
return awgSizes[Int(awg)] ?? 0.75
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class ElectricalSystem {
|
||||
var name: String = ""
|
||||
var location: String = ""
|
||||
var timestamp: Date = Date()
|
||||
var iconName: String = "building.2"
|
||||
var colorName: String = "blue"
|
||||
|
||||
init(name: String, location: String = "", iconName: String = "building.2", colorName: String = "blue") {
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.timestamp = Date()
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
class SavedLoad {
|
||||
var name: String = ""
|
||||
var voltage: Double = 0.0
|
||||
var current: Double = 0.0
|
||||
var power: Double = 0.0
|
||||
var length: Double = 0.0
|
||||
var crossSection: Double = 0.0
|
||||
var timestamp: Date = Date()
|
||||
var iconName: String = "lightbulb"
|
||||
var colorName: String = "blue"
|
||||
var isWattMode: Bool = false
|
||||
var system: ElectricalSystem?
|
||||
var remoteIconURLString: String? = nil
|
||||
var affiliateURLString: String? = nil
|
||||
var affiliateCountryCode: String? = nil
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var identifier: String = UUID().uuidString
|
||||
|
||||
init(name: String, voltage: Double, current: Double, power: Double, length: Double, crossSection: Double, iconName: String = "lightbulb", colorName: String = "blue", isWattMode: Bool = false, system: ElectricalSystem? = nil, remoteIconURLString: String? = nil, affiliateURLString: String? = nil, affiliateCountryCode: String? = nil, bomCompletedItemIDs: [String] = [], identifier: String = UUID().uuidString) {
|
||||
self.name = name
|
||||
self.voltage = voltage
|
||||
self.current = current
|
||||
self.power = power
|
||||
self.length = length
|
||||
self.crossSection = crossSection
|
||||
self.timestamp = Date()
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.isWattMode = isWattMode
|
||||
self.system = system
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.identifier = identifier
|
||||
}
|
||||
}
|
||||
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,121 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ComponentsOnboardingView: View {
|
||||
@State private var carouselStep = 0
|
||||
let onCreate: () -> Void
|
||||
let onBrowse: () -> Void
|
||||
|
||||
private let imageNames = [
|
||||
"router-onboarding",
|
||||
"coffee-onboarding",
|
||||
"charger-onboarding"
|
||||
]
|
||||
|
||||
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
|
||||
private let animationDuration = 0.8
|
||||
|
||||
private var loopingImages: [String] {
|
||||
guard let first = imageNames.first else { return [] }
|
||||
return imageNames + [first]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer(minLength: 32)
|
||||
|
||||
OnboardingCarouselView(images: loopingImages, step: carouselStep)
|
||||
.frame(minHeight: 80, maxHeight: 240)
|
||||
.padding(.horizontal, 0)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Add your first component")
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(minHeight: 72)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button(action: createComponent) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Create Component")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.accessibilityIdentifier("create-component-button")
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onBrowse) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "books.vertical")
|
||||
.font(.system(size: 16))
|
||||
Text("Browse Library")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.blue.opacity(0.12))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear(perform: resetState)
|
||||
.onReceive(timer) { _ in advanceCarousel() }
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
carouselStep = 0
|
||||
}
|
||||
|
||||
private func createComponent() {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
private func advanceCarousel() {
|
||||
guard imageNames.count > 1 else { return }
|
||||
let next = carouselStep + 1
|
||||
|
||||
withAnimation(.easeInOut(duration: animationDuration)) {
|
||||
carouselStep = next
|
||||
}
|
||||
|
||||
if next == imageNames.count {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
|
||||
withAnimation(.none) {
|
||||
carouselStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ComponentsOnboardingView(onCreate: {}, onBrowse: {})
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,10 +40,24 @@ 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.region?.identifier)
|
||||
}
|
||||
|
||||
func localizedName(for locale: Locale) -> String {
|
||||
translation(for: locale) ?? name
|
||||
}
|
||||
|
||||
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
|
||||
guard !affiliateLinks.isEmpty else { return nil }
|
||||
|
||||
@@ -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
|
||||
@@ -113,7 +221,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
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"),
|
||||
URLQueryItem(name: "fields", value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt"),
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "perPage", value: "\(perPage)")
|
||||
]
|
||||
@@ -153,6 +261,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
ComponentLibraryItem(
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
translations: record.translations?.flattened ?? [:],
|
||||
voltageIn: record.voltageIn,
|
||||
voltageOut: record.voltageOut,
|
||||
watt: record.watt,
|
||||
@@ -300,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?
|
||||
@@ -309,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 {
|
||||
@@ -346,6 +509,7 @@ struct ComponentLibraryView: View {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier("library-view-close-button")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,7 +559,8 @@ struct ComponentLibraryView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
} else {
|
||||
List(filteredItems) { item in
|
||||
List {
|
||||
ForEach(filteredItems) { item in
|
||||
Button {
|
||||
onSelect(item)
|
||||
dismiss()
|
||||
@@ -404,6 +569,8 @@ struct ComponentLibraryView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
poweredByVoltplanRow
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
@@ -413,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,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
|
||||
}
|
||||
@@ -75,3 +75,77 @@ struct LoadIconView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentSummaryMetricView: View {
|
||||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
Text(value)
|
||||
.font(.body.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentMetricBadgeView: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label.uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(tint.opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Color {
|
||||
static func componentColor(named colorName: String) -> Color {
|
||||
switch colorName {
|
||||
case "blue": return .blue
|
||||
case "green": return .green
|
||||
case "orange": return .orange
|
||||
case "red": return .red
|
||||
case "purple": return .purple
|
||||
case "yellow": return .yellow
|
||||
case "pink": return .pink
|
||||
case "teal": return .teal
|
||||
case "indigo": return .indigo
|
||||
case "mint": return .mint
|
||||
case "cyan": return .cyan
|
||||
case "brown": return .brown
|
||||
case "gray": return .gray
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
1003
Cable/Loads/LoadsView.swift
Normal file
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"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,9 @@ struct SystemsOnboardingView: View {
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear(perform: resetState)
|
||||
.onReceive(timer) { _ in advanceCarousel() }
|
||||
.task {
|
||||
AnalyticsTracker.log("Launched")
|
||||
}
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
@@ -102,6 +105,7 @@ struct SystemsOnboardingView: View {
|
||||
private func createSystem() {
|
||||
isFieldFocused = false
|
||||
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
AnalyticsTracker.log("System Created", properties: ["name": trimmed])
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onCreate(trimmed)
|
||||
}
|
||||
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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,138 @@
|
||||
// Keys
|
||||
"Add your first component" = "Erstelle deine erste Komponente";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
|
||||
"Browse" = "Durchsuchen";
|
||||
"Browse Library" = "Bibliothek durchsuchen";
|
||||
"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern";
|
||||
"Cancel" = "Abbrechen";
|
||||
"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus.";
|
||||
"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.";
|
||||
"Close" = "Schließen";
|
||||
"Color" = "Farbe";
|
||||
"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar – verwalte hier deine elektrischen Systeme und Verteilungen.";
|
||||
"Component Library" = "Komponentenbibliothek";
|
||||
"Components" = "Verbraucher";
|
||||
"Create Component" = "Komponente erstellen";
|
||||
"Create System" = "System erstellen";
|
||||
"Create your first system" = "Erstelle dein erstes System";
|
||||
"Current" = "Strom";
|
||||
"Current Units" = "Aktuelle Einheiten";
|
||||
"Details" = "Details";
|
||||
"Details coming soon" = "Details folgen in Kürze";
|
||||
"Edit Current" = "Strom bearbeiten";
|
||||
"Edit Length" = "Länge bearbeiten";
|
||||
"Edit Power" = "Leistung bearbeiten";
|
||||
"Edit Voltage" = "Spannung bearbeiten";
|
||||
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
|
||||
"Enter length in %@" = "Gib die Länge in %@ ein";
|
||||
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
|
||||
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
|
||||
"FUSE" = "SICHERUNG";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
|
||||
"Icon" = "Symbol";
|
||||
"Important:" = "Wichtig:";
|
||||
"Length" = "Länge";
|
||||
"Length:" = "Länge:";
|
||||
"Load Library" = "Verbraucher-bibliothek";
|
||||
"Loading components" = "Komponenten werden geladen";
|
||||
"New Load" = "Neuer Verbraucher";
|
||||
"No components available" = "Keine Komponenten verfügbar";
|
||||
"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher.";
|
||||
"No matches" = "Keine Treffer";
|
||||
"Power" = "Leistung";
|
||||
"Preview" = "Vorschau";
|
||||
"Retry" = "Erneut versuchen";
|
||||
"Safety Disclaimer" = "Sicherheitshinweis";
|
||||
"Save" = "Speichern";
|
||||
"Search components" = "Komponenten suchen";
|
||||
"Settings" = "Einstellungen";
|
||||
"System" = "System";
|
||||
"System Name" = "Systemname";
|
||||
"System View" = "Systemansicht";
|
||||
"Systems" = "Systeme";
|
||||
"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.";
|
||||
"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen.";
|
||||
"Unable to load components" = "Komponenten konnten nicht geladen werden";
|
||||
"Unit System" = "Einheitensystem";
|
||||
"Units" = "Einheiten";
|
||||
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
||||
"Voltage" = "Spannung";
|
||||
"WIRE" = "KABEL";
|
||||
"Wire Cross-Section:" = "Kabelquerschnitt:";
|
||||
"affiliate.button.review_parts" = "Bauteile prüfen";
|
||||
"affiliate.description.with_link" = "Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen.";
|
||||
"affiliate.description.without_link" = "Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen.";
|
||||
"affiliate.disclaimer" = "Käufe über Affiliate-Links können VoltPlan unterstützen.";
|
||||
"battery.bank.badge.capacity" = "Kapazität";
|
||||
"battery.bank.badge.energy" = "Energie";
|
||||
"battery.bank.badge.voltage" = "Spannung";
|
||||
"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt";
|
||||
"battery.bank.banner.voltage" = "Spannungsabweichung erkannt";
|
||||
"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren.";
|
||||
"battery.bank.empty.title" = "Noch keine Batterien";
|
||||
"battery.bank.header.title" = "Batterien";
|
||||
"battery.bank.metric.capacity" = "Kapazität";
|
||||
"battery.bank.metric.count" = "Batterien";
|
||||
"battery.bank.metric.energy" = "Energie";
|
||||
"battery.bank.metric.usable_capacity" = "Nutzbare Kapazität";
|
||||
"battery.bank.metric.usable_energy" = "Nutzbare Energie";
|
||||
"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung.";
|
||||
"battery.bank.status.capacity.title" = "Kapazitätsabweichung";
|
||||
"battery.bank.status.dismiss" = "Verstanden";
|
||||
"battery.bank.status.multiple.batteries" = "%d Batterien";
|
||||
"battery.bank.status.single.battery" = "Eine Batterie";
|
||||
"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen.";
|
||||
"battery.bank.status.voltage.title" = "Spannungsabweichung";
|
||||
"battery.bank.warning.capacity.short" = "Kapazität";
|
||||
"battery.bank.warning.voltage.short" = "Spannung";
|
||||
"battery.editor.advanced.usable_capacity.footer_default" = "Standardwert %@ basierend auf der Chemie.";
|
||||
"battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@.";
|
||||
"battery.editor.advanced.charge_voltage.helper" = "Lege die maximal empfohlene Ladespannung fest.";
|
||||
"battery.editor.advanced.cutoff_voltage.helper" = "Lege die minimale sichere Entladespannung fest.";
|
||||
"battery.editor.advanced.temperature_range.helper" = "Definiere den empfohlenen Betriebstemperaturbereich.";
|
||||
"battery.editor.alert.charge_voltage.message" = "Gib die Ladespannung in Volt (V) ein.";
|
||||
"battery.editor.alert.charge_voltage.placeholder" = "Ladespannung";
|
||||
"battery.editor.alert.charge_voltage.title" = "Ladespannung bearbeiten";
|
||||
"battery.editor.alert.cutoff_voltage.message" = "Gib die Abschaltspannung in Volt (V) ein.";
|
||||
"battery.editor.alert.cutoff_voltage.placeholder" = "Abschaltspannung";
|
||||
"battery.editor.alert.cutoff_voltage.title" = "Abschaltspannung bearbeiten";
|
||||
"battery.editor.alert.maximum_temperature.message" = "Gib die Höchsttemperatur in Grad Celsius (\u00B0C) ein.";
|
||||
"battery.editor.alert.maximum_temperature.placeholder" = "Höchsttemperatur (\u00B0C)";
|
||||
"battery.editor.alert.maximum_temperature.title" = "Höchsttemperatur bearbeiten";
|
||||
"battery.editor.alert.minimum_temperature.message" = "Gib die Mindesttemperatur in Grad Celsius (\u00B0C) ein.";
|
||||
"battery.editor.alert.minimum_temperature.placeholder" = "Mindesttemperatur (\u00B0C)";
|
||||
"battery.editor.alert.minimum_temperature.title" = "Mindesttemperatur bearbeiten";
|
||||
"battery.editor.alert.cancel" = "Abbrechen";
|
||||
"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben";
|
||||
"battery.editor.alert.capacity.placeholder" = "Kapazität";
|
||||
"battery.editor.alert.capacity.title" = "Kapazität bearbeiten";
|
||||
"battery.editor.alert.save" = "Speichern";
|
||||
"battery.editor.alert.usable_capacity.message" = "Nutzbare Kapazität in Prozent (%) eingeben";
|
||||
"battery.editor.alert.usable_capacity.placeholder" = "Nutzbare Kapazität (%)";
|
||||
"battery.editor.alert.usable_capacity.title" = "Nutzbare Kapazität bearbeiten";
|
||||
"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
|
||||
"battery.editor.alert.voltage.placeholder" = "Spannung";
|
||||
"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten";
|
||||
"battery.editor.button.reset_default" = "Zurücksetzen";
|
||||
"battery.editor.cancel" = "Abbrechen";
|
||||
"battery.editor.default_name" = "Neue Batterie";
|
||||
"battery.editor.field.chemistry" = "Chemie";
|
||||
"battery.editor.field.name" = "Name";
|
||||
"battery.editor.placeholder.name" = "Hausbank";
|
||||
"battery.editor.save" = "Speichern";
|
||||
"battery.editor.section.advanced" = "Erweitert";
|
||||
"battery.editor.section.summary" = "Übersicht";
|
||||
"battery.editor.slider.capacity" = "Kapazität";
|
||||
"battery.editor.slider.charge_voltage" = "Ladespannung";
|
||||
"battery.editor.slider.cutoff_voltage" = "Abschaltspannung";
|
||||
"battery.editor.slider.temperature_range" = "Temperaturbereich";
|
||||
"battery.editor.slider.temperature_range.max" = "Maximum";
|
||||
"battery.editor.slider.temperature_range.min" = "Minimum";
|
||||
"battery.editor.slider.usable_capacity" = "Nutzbare Kapazität (%)";
|
||||
"battery.editor.slider.voltage" = "Nennspannung";
|
||||
"battery.editor.title" = "Batterie einrichten";
|
||||
"battery.onboarding.subtitle" = "Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten.";
|
||||
"battery.onboarding.title" = "Füge deine erste Batterie hinzu";
|
||||
"battery.overview.empty.create" = "Batterie hinzufügen";
|
||||
"bom.accessibility.mark.complete" = "Markiere %@ als erledigt";
|
||||
"bom.accessibility.mark.incomplete" = "Markiere %@ als unerledigt";
|
||||
"bom.fuse.detail" = "Inline-Halter und %dA-Sicherung";
|
||||
@@ -14,11 +144,93 @@
|
||||
"bom.navigation.title.system" = "Stückliste – %@";
|
||||
"bom.size.unknown" = "Größe offen";
|
||||
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
||||
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
|
||||
"bom.export.pdf.button" = "PDF exportieren";
|
||||
"bom.export.pdf.error.title" = "Export fehlgeschlagen";
|
||||
"bom.export.pdf.error.empty" = "Füge vor dem Export mindestens eine Komponente hinzu.";
|
||||
"bom.pdf.header.title" = "System-Stückliste";
|
||||
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||
"bom.pdf.header.inline" = "Einheitensystem: %@";
|
||||
"bom.pdf.placeholder.empty" = "Keine Komponenten verfügbar.";
|
||||
"bom.pdf.page.number" = "Seite %d";
|
||||
"bom.category.components.title" = "Komponenten & Ladegeräte";
|
||||
"bom.category.components.subtitle" = "Hauptverbraucher, Regler und Ladehardware.";
|
||||
"bom.category.batteries.title" = "Batterien";
|
||||
"bom.category.batteries.subtitle" = "Hausspeicher und Batteriebänke.";
|
||||
"bom.category.cables.title" = "Kabel";
|
||||
"bom.category.cables.subtitle" = "Passende Leitungen für jede Strecke.";
|
||||
"bom.category.fuses.title" = "Sicherungen";
|
||||
"bom.category.fuses.subtitle" = "Stromkreisschutz und Halter.";
|
||||
"bom.category.accessories.title" = "Zubehör";
|
||||
"bom.category.accessories.subtitle" = "Sicherungen, Kabelschuhe und weiteres Montagematerial.";
|
||||
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||
"bom.quantity.count.badge" = "%d×";
|
||||
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
|
||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.single.badge" = "1× • %@";
|
||||
"cable.pro.privacy.label" = "Datenschutz";
|
||||
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
||||
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
||||
"cable.pro.terms.url" = "https://voltplan.app/de/agb";
|
||||
"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
|
||||
"calculator.advanced.duty_cycle.title" = "Einschaltdauer";
|
||||
"calculator.advanced.section.title" = "Erweitert";
|
||||
"calculator.advanced.usage_hours.helper" = "Stunden pro Tag, in denen die Last eingeschaltet ist.";
|
||||
"calculator.advanced.usage_hours.title" = "Tägliche Laufzeit";
|
||||
"calculator.advanced.usage_hours.unit" = "h/Tag";
|
||||
"calculator.alert.duty_cycle.message" = "Einschaltdauer als Prozent (0-100 %) eingeben.";
|
||||
"calculator.alert.duty_cycle.placeholder" = "Einschaltdauer";
|
||||
"calculator.alert.duty_cycle.title" = "Einschaltdauer bearbeiten";
|
||||
"calculator.alert.usage_hours.message" = "Stunden pro Tag eingeben, in denen die Last aktiv ist.";
|
||||
"calculator.alert.usage_hours.placeholder" = "Tägliche Laufzeit";
|
||||
"calculator.alert.usage_hours.title" = "Tägliche Laufzeit bearbeiten";
|
||||
"charger.default.new" = "Neues Ladegerät";
|
||||
"charger.editor.alert.cancel" = "Abbrechen";
|
||||
"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben";
|
||||
"charger.editor.alert.current.title" = "Ladestrom bearbeiten";
|
||||
"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten";
|
||||
"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten";
|
||||
"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben";
|
||||
"charger.editor.alert.power.placeholder" = "Leistung";
|
||||
"charger.editor.alert.power.title" = "Ladeleistung bearbeiten";
|
||||
"charger.editor.alert.save" = "Speichern";
|
||||
"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
|
||||
"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten";
|
||||
"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird";
|
||||
"charger.editor.appearance.title" = "Ladegerät-Darstellung";
|
||||
"charger.editor.default_name" = "Neues Ladegerät";
|
||||
"charger.editor.field.current" = "Ladestrom";
|
||||
"charger.editor.field.input_voltage" = "Eingangsspannung";
|
||||
"charger.editor.field.name" = "Name";
|
||||
"charger.editor.field.output_voltage" = "Ausgangsspannung";
|
||||
"charger.editor.field.power" = "Ladeleistung";
|
||||
"charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom.";
|
||||
"charger.editor.placeholder.name" = "Werkstattladegerät";
|
||||
"charger.editor.section.electrical" = "Elektrik";
|
||||
"charger.editor.section.power" = "Ladeausgang";
|
||||
"charger.editor.title" = "Ladegerät";
|
||||
"chargers.badge.current" = "Strom";
|
||||
"chargers.badge.input" = "Eingang";
|
||||
"chargers.badge.output" = "Ausgang";
|
||||
"chargers.badge.power" = "Leistung";
|
||||
"chargers.onboarding.primary" = "Ladegerät erstellen";
|
||||
"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten.";
|
||||
"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu";
|
||||
"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar.";
|
||||
"chargers.summary.metric.count" = "Ladegeräte";
|
||||
"chargers.summary.metric.current" = "Ladestrom";
|
||||
"chargers.summary.metric.output" = "Spannung";
|
||||
"chargers.summary.metric.power" = "Ladeleistung";
|
||||
"chargers.summary.title" = "Ladeübersicht";
|
||||
"chargers.title" = "Ladegeräte für %@";
|
||||
"component.fallback.name" = "Komponente";
|
||||
"default.load.library" = "Bibliothekslast";
|
||||
"default.load.name" = "Mein Verbraucher";
|
||||
"default.load.unnamed" = "Unbenannter Verbraucher";
|
||||
"default.load.new" = "Neuer Verbraucher";
|
||||
"default.load.unnamed" = "Unbenannter Verbraucher";
|
||||
"default.system.name" = "Mein System";
|
||||
"default.system.new" = "Neues System";
|
||||
"editor.load.name_field" = "Name des Verbrauchers";
|
||||
@@ -27,79 +239,153 @@
|
||||
"editor.system.location.optional" = "Standort (optional)";
|
||||
"editor.system.name_field" = "Name des Systems";
|
||||
"editor.system.title" = "System bearbeiten";
|
||||
"loads.library.button" = "Bibliothek";
|
||||
"loads.metric.cable" = "Schnitt";
|
||||
"loads.metric.fuse" = "Sicherung";
|
||||
"loads.metric.length" = "Länge";
|
||||
"loads.onboarding.subtitle" = "Statte dein System mit Verbrauchern aus und lass **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen übernehmen.";
|
||||
"loads.onboarding.title" = "Erstelle deinen ersten Verbraucher";
|
||||
"loads.overview.empty.create" = "Verbraucher hinzufügen";
|
||||
"loads.overview.empty.library" = "Bibliothek durchsuchen";
|
||||
"loads.overview.empty.message" = "Füge einen Verbraucher hinzu, um dein System einzurichten.";
|
||||
"loads.overview.header.title" = "Verbraucher";
|
||||
"loads.overview.metric.count" = "Verbraucher";
|
||||
"loads.overview.metric.current" = "Strom";
|
||||
"loads.overview.metric.power" = "Leistung";
|
||||
"loads.overview.status.missing_details.banner" = "Konfiguration deiner Verbraucher abschließen";
|
||||
"loads.overview.status.missing_details.message" = "Gib Kabellänge und Leitungsquerschnitt für %d %@ ein, um genaue Empfehlungen zu erhalten.";
|
||||
"loads.overview.status.missing_details.plural" = "Verbraucher";
|
||||
"loads.overview.status.missing_details.singular" = "Verbraucher";
|
||||
"loads.overview.status.missing_details.title" = "Fehlende Verbraucherdetails";
|
||||
"overview.chargers.empty.create" = "Ladegerät hinzufügen";
|
||||
"overview.chargers.empty.subtitle" = "Füge Landstrom-, DC-DC- oder Solarladegeräte hinzu, um deine Ladeleistung zu verstehen.";
|
||||
"overview.chargers.empty.title" = "Noch keine Ladegeräte konfiguriert";
|
||||
"overview.chargers.header.title" = "Ladegeräte";
|
||||
"overview.loads.empty.subtitle" = "Füge Verbraucher hinzu, um auf dieses System zugeschnittene Kabel- und Sicherungsempfehlungen zu erhalten.";
|
||||
"overview.loads.empty.title" = "Noch keine Verbraucher eingerichtet";
|
||||
"overview.runtime.subtitle" = "Bei dauerhafter Vollast";
|
||||
"overview.runtime.title" = "Geschätzte Laufzeit";
|
||||
"overview.runtime.unavailable" = "Trage Batteriekapazität und Leistungsaufnahme ein, um die Laufzeit zu schätzen.";
|
||||
"overview.system.header.title" = "Systemübersicht";
|
||||
"overview.bom.title" = "Stückliste";
|
||||
"overview.bom.subtitle" = "Tippe, um Komponenten zu prüfen";
|
||||
"overview.bom.unavailable" = "Füge Verbraucher hinzu, um Komponenten zu erzeugen.";
|
||||
"overview.bom.placeholder.short" = "Verbraucher hinzufügen";
|
||||
"overview.chargetime.title" = "Geschätzte Ladezeit";
|
||||
"overview.chargetime.subtitle" = "Bei kombinierter Laderate";
|
||||
"overview.chargetime.unavailable" = "Füge Ladegeräte und Batteriekapazität hinzu, um eine Schätzung zu erhalten.";
|
||||
"overview.chargetime.placeholder.short" = "Ladegeräte hinzufügen";
|
||||
"overview.goal.prefix" = "Ziel";
|
||||
"overview.goal.label" = "Ziel %@";
|
||||
"overview.goal.clear" = "Ziel entfernen";
|
||||
"overview.goal.cancel" = "Abbrechen";
|
||||
"overview.goal.save" = "Speichern";
|
||||
"overview.runtime.goal.title" = "Laufzeit-Ziel";
|
||||
"overview.chargetime.goal.title" = "Ladezeit-Ziel";
|
||||
"overview.runtime.placeholder.short" = "Kapazität hinzufügen";
|
||||
"sample.battery.rv.name" = "LiFePO4-Bordbatterie";
|
||||
"sample.battery.workshop.name" = "Werkbank-Reservebatterie";
|
||||
"sample.charger.dcdc.name" = "DC-DC-Ladegerät";
|
||||
"sample.charger.shore.name" = "Landstrom-Ladegerät";
|
||||
"sample.charger.workbench.name" = "Werkbank-Ladegerät";
|
||||
"sample.load.charger.name" = "Werkzeugladegerät";
|
||||
"sample.load.compressor.name" = "Luftkompressor";
|
||||
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
||||
"sample.load.lighting.name" = "LED-Streifen";
|
||||
"sample.system.rv.location" = "12V Wohnstromkreis";
|
||||
"sample.system.rv.name" = "Abenteuer-Van";
|
||||
"sample.system.workshop.location" = "Werkzeugecke";
|
||||
"sample.system.workshop.name" = "Werkbank";
|
||||
"slider.button.ampere" = "Ampere";
|
||||
"slider.button.watt" = "Watt";
|
||||
"slider.current.title" = "Strom";
|
||||
"slider.length.title" = "Kabellänge (%@)";
|
||||
"slider.power.title" = "Leistung";
|
||||
"slider.voltage.title" = "Spannung";
|
||||
"system.list.no.components" = "Noch keine Komponenten";
|
||||
"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)";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systeme";
|
||||
"System" = "System";
|
||||
"System View" = "Systemansicht";
|
||||
"System Name" = "Systemname";
|
||||
"Create System" = "System erstellen";
|
||||
"Create your first system" = "Erstelle dein erstes System";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Gib deinem System einen Namen, damit **Cable by VoltPlan** alle zusammengehörenden Verbraucher gruppieren kann.";
|
||||
"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." = "Erwecke dein System mit Komponenten zum Leben und überlasse **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen.";
|
||||
"Create Component" = "Komponente erstellen";
|
||||
"Browse Library" = "Bibliothek durchsuchen";
|
||||
"Browse" = "Durchsuchen";
|
||||
"Browse electrical components from VoltPlan" = "Elektrische Komponenten von VoltPlan durchstöbern";
|
||||
"Component Library" = "Komponentenbibliothek";
|
||||
"Details coming soon" = "Details folgen in Kürze";
|
||||
"Components" = "Komponenten";
|
||||
"FUSE" = "SICHERUNG";
|
||||
"WIRE" = "KABEL";
|
||||
"Current" = "Strom";
|
||||
"Power" = "Leistung";
|
||||
"Voltage" = "Spannung";
|
||||
"Length" = "Länge";
|
||||
"Length:" = "Länge:";
|
||||
"Wire Cross-Section:" = "Kabelquerschnitt:";
|
||||
"Current Units" = "Aktuelle Einheiten";
|
||||
"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus.";
|
||||
"Unit System" = "Einheitensystem";
|
||||
"Units" = "Einheiten";
|
||||
"Settings" = "Einstellungen";
|
||||
"Close" = "Schließen";
|
||||
"Cancel" = "Abbrechen";
|
||||
"Save" = "Speichern";
|
||||
"Retry" = "Erneut versuchen";
|
||||
"Loading components" = "Komponenten werden geladen";
|
||||
"Unable to load components" = "Komponenten konnten nicht geladen werden";
|
||||
"No components available" = "Keine Komponenten verfügbar";
|
||||
"No matches" = "Keine Treffer";
|
||||
"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.";
|
||||
"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen.";
|
||||
"Search components" = "Komponenten suchen";
|
||||
"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher.";
|
||||
"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar – verwalte hier deine elektrischen Systeme und Verteilungen.";
|
||||
"Load Library" = "Verbraucher-bibliothek";
|
||||
"Safety Disclaimer" = "Sicherheitshinweis";
|
||||
"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.";
|
||||
"Important:" = "Wichtig:";
|
||||
"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";
|
||||
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
|
||||
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
|
||||
"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";
|
||||
"• 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";
|
||||
"Enter length in %@" = "Gib die Länge in %@ ein";
|
||||
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
|
||||
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
|
||||
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
|
||||
"Edit Length" = "Länge bearbeiten";
|
||||
"Edit Voltage" = "Spannung bearbeiten";
|
||||
"Edit Current" = "Strom bearbeiten";
|
||||
"Edit Power" = "Leistung bearbeiten";
|
||||
"Preview" = "Vorschau";
|
||||
"Details" = "Details";
|
||||
"Icon" = "Symbol";
|
||||
"Color" = "Farbe";
|
||||
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
||||
"New Load" = "Neuer Verbraucher";
|
||||
"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
"bom.navigation.title.system" = "Lista de materiales – %@";
|
||||
"bom.size.unknown" = "Tamaño por definir";
|
||||
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
||||
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
|
||||
"bom.export.pdf.button" = "Exportar PDF";
|
||||
"bom.export.pdf.error.title" = "Exportación fallida";
|
||||
"bom.export.pdf.error.empty" = "Agrega al menos un componente antes de exportar.";
|
||||
"bom.pdf.header.title" = "Lista de materiales del sistema";
|
||||
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||
"bom.pdf.header.inline" = "Sistema de unidades: %@";
|
||||
"bom.pdf.placeholder.empty" = "No hay componentes disponibles.";
|
||||
"bom.pdf.page.number" = "Página %d";
|
||||
"bom.category.components.title" = "Componentes y cargadores";
|
||||
"bom.category.components.subtitle" = "Dispositivos principales, controladores y equipos de carga.";
|
||||
"bom.category.batteries.title" = "Baterías";
|
||||
"bom.category.batteries.subtitle" = "Bancos domésticos y almacenamiento.";
|
||||
"bom.category.cables.title" = "Cables";
|
||||
"bom.category.cables.subtitle" = "Tendidos dimensionados para cada circuito.";
|
||||
"bom.category.fuses.title" = "Fusibles";
|
||||
"bom.category.fuses.subtitle" = "Protección de circuitos y portafusibles.";
|
||||
"bom.category.accessories.title" = "Accesorios";
|
||||
"bom.category.accessories.subtitle" = "Fusibles, terminales y piezas de soporte.";
|
||||
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||
"bom.quantity.count.badge" = "%d×";
|
||||
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
|
||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.single.badge" = "1× • %@";
|
||||
"component.fallback.name" = "Componente";
|
||||
"default.load.library" = "Carga de la biblioteca";
|
||||
"default.load.name" = "Mi carga";
|
||||
@@ -33,9 +60,53 @@
|
||||
"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";
|
||||
@@ -102,3 +173,181 @@
|
||||
"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";
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
"bom.navigation.title.system" = "Liste de matériel – %@";
|
||||
"bom.size.unknown" = "Taille à déterminer";
|
||||
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
||||
"bom.empty.message" = "Aucun composant enregistré pour ce système pour 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";
|
||||
@@ -33,9 +60,53 @@
|
||||
"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";
|
||||
@@ -102,3 +173,181 @@
|
||||
"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";
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
"bom.navigation.title.system" = "Materiaallijst – %@";
|
||||
"bom.size.unknown" = "Afmeting nog onbekend";
|
||||
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
||||
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
|
||||
"bom.export.pdf.button" = "PDF exporteren";
|
||||
"bom.export.pdf.error.title" = "Export mislukt";
|
||||
"bom.export.pdf.error.empty" = "Voeg minimaal één component toe voordat je exporteert.";
|
||||
"bom.pdf.header.title" = "Stuklijst van het systeem";
|
||||
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||
"bom.pdf.header.inline" = "Maateenheid: %@";
|
||||
"bom.pdf.placeholder.empty" = "Geen componenten beschikbaar.";
|
||||
"bom.pdf.page.number" = "Pagina %d";
|
||||
"bom.category.components.title" = "Componenten en laders";
|
||||
"bom.category.components.subtitle" = "Hoofdapparaten, regelaars en laadapparatuur.";
|
||||
"bom.category.batteries.title" = "Batterijen";
|
||||
"bom.category.batteries.subtitle" = "Huishoudbanken en opslag.";
|
||||
"bom.category.cables.title" = "Kabels";
|
||||
"bom.category.cables.subtitle" = "Op maat gemaakte stroomtrajecten per circuit.";
|
||||
"bom.category.fuses.title" = "Zekeringen";
|
||||
"bom.category.fuses.subtitle" = "Circuitbeveiliging en houders.";
|
||||
"bom.category.accessories.title" = "Accessoires";
|
||||
"bom.category.accessories.subtitle" = "Zekeringen, kabelschoenen en ondersteunende onderdelen.";
|
||||
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||
"bom.quantity.count.badge" = "%d×";
|
||||
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
|
||||
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
|
||||
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
|
||||
"bom.quantity.single.badge" = "1× • %@";
|
||||
"component.fallback.name" = "Component";
|
||||
"default.load.library" = "Bibliotheeklast";
|
||||
"default.load.name" = "Mijn last";
|
||||
@@ -33,9 +60,53 @@
|
||||
"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";
|
||||
@@ -102,3 +173,181 @@
|
||||
"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";
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,603 @@
|
||||
//
|
||||
// CableUITestsScreenshot.swift
|
||||
// CableUITestsScreenshot
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshot: XCTestCase {
|
||||
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
|
||||
private enum UIStringKey: String {
|
||||
case addLoad
|
||||
case browseLibrary
|
||||
case library
|
||||
case overviewTab
|
||||
case componentsTab
|
||||
case batteriesTab
|
||||
case chargersTab
|
||||
case close
|
||||
case cancel
|
||||
case settings
|
||||
case defaultLoadName
|
||||
case billOfMaterials
|
||||
case systemEditorTitle
|
||||
case systemsTitle
|
||||
}
|
||||
|
||||
private let translations: [UIStringKey: [String: String]] = [
|
||||
.addLoad: [
|
||||
"en": "Add Load",
|
||||
"de": "Verbraucher hinzufügen",
|
||||
"es": "Añadir carga",
|
||||
"fr": "Ajouter une charge",
|
||||
"nl": "Belasting toevoegen",
|
||||
],
|
||||
.browseLibrary: [
|
||||
"en": "Browse Library",
|
||||
"de": "Bibliothek durchsuchen",
|
||||
"es": "Explorar biblioteca",
|
||||
"fr": "Parcourir la bibliothèque",
|
||||
"nl": "Bibliotheek bekijken",
|
||||
],
|
||||
.library: [
|
||||
"en": "Library",
|
||||
"de": "Bibliothek",
|
||||
"es": "Biblioteca",
|
||||
"fr": "Bibliothèque",
|
||||
"nl": "Bibliotheek",
|
||||
],
|
||||
.overviewTab: [
|
||||
"en": "Overview",
|
||||
"de": "Übersicht",
|
||||
"es": "Resumen",
|
||||
"fr": "Aperçu",
|
||||
"nl": "Overzicht",
|
||||
],
|
||||
.componentsTab: [
|
||||
"en": "Components",
|
||||
"de": "Verbraucher",
|
||||
"es": "Componentes",
|
||||
"fr": "Composants",
|
||||
"nl": "Componenten",
|
||||
],
|
||||
.batteriesTab: [
|
||||
"en": "Batteries",
|
||||
"de": "Batterien",
|
||||
"es": "Baterías",
|
||||
"fr": "Batteries",
|
||||
"nl": "Batterijen",
|
||||
],
|
||||
.chargersTab: [
|
||||
"en": "Chargers",
|
||||
"de": "Ladegeräte",
|
||||
"es": "Cargadores",
|
||||
"fr": "Chargeurs",
|
||||
"nl": "Laders",
|
||||
],
|
||||
.close: [
|
||||
"en": "Close",
|
||||
"de": "Schließen",
|
||||
"es": "Cerrar",
|
||||
"fr": "Fermer",
|
||||
"nl": "Sluiten",
|
||||
],
|
||||
.cancel: [
|
||||
"en": "Cancel",
|
||||
"de": "Abbrechen",
|
||||
"es": "Cancelar",
|
||||
"fr": "Annuler",
|
||||
"nl": "Annuleren",
|
||||
],
|
||||
.settings: [
|
||||
"en": "Settings",
|
||||
"de": "Einstellungen",
|
||||
"es": "Configuración",
|
||||
"fr": "Réglages",
|
||||
"nl": "Instellingen",
|
||||
],
|
||||
.defaultLoadName: [
|
||||
"en": "New Load",
|
||||
"de": "Neuer Verbraucher",
|
||||
"es": "Carga nueva",
|
||||
"fr": "Nouvelle charge",
|
||||
"nl": "Nieuwe last",
|
||||
],
|
||||
.billOfMaterials: [
|
||||
"en": "Bill of Materials",
|
||||
"de": "Stückliste",
|
||||
"es": "Lista de materiales",
|
||||
"fr": "Liste de matériel",
|
||||
"nl": "Stuklijst",
|
||||
],
|
||||
.systemEditorTitle: [
|
||||
"en": "Edit System",
|
||||
"de": "System bearbeiten",
|
||||
"es": "Editar sistema",
|
||||
"fr": "Modifier le système",
|
||||
"nl": "Systeem bewerken",
|
||||
],
|
||||
.systemsTitle: [
|
||||
"en": "Systems",
|
||||
"de": "Systeme",
|
||||
"es": "Sistemas",
|
||||
"fr": "Systèmes",
|
||||
"nl": "Systemen",
|
||||
],
|
||||
]
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
try super.setUpWithError()
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
//ensureDoNotDisturbEnabled()
|
||||
//dismissSystemOverlays()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
try super.tearDownWithError()
|
||||
//dismissSystemOverlays()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
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()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
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()
|
||||
}
|
||||
|
||||
// @MainActor
|
||||
// func testLaunchPerformance() throws {
|
||||
// // This measures how long it takes to launch your application.
|
||||
// measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
// XCUIApplication().launch()
|
||||
// }
|
||||
// }
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,46 @@
|
||||
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
|
||||
@@ -17,35 +57,110 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testOnboardingLoadsView() throws {
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
app.launch()
|
||||
snapshot("0OnboardingSystemsView")
|
||||
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")
|
||||
|
||||
snapshot("1OnboardingLoadsView")
|
||||
let createComponentButton = app.buttons["create-component-button"]
|
||||
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()
|
||||
snapshot("2LoadEditorView")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
//
|
||||
// Created by Felix Krause on 10/8/15.
|
||||
//
|
||||
|
||||
// -----------------------------------------------------
|
||||
// IMPORTANT: When modifying this file, make sure to
|
||||
// increment the version number at the very
|
||||
// bottom of the file to notify users about
|
||||
// the new SnapshotHelper.swift
|
||||
// -----------------------------------------------------
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
} else {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
case cannotFindSimulatorHomeDirectory
|
||||
case cannotRunOnPhysicalDevice
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .cannotFindSimulatorHomeDirectory:
|
||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
||||
case .cannotRunOnPhysicalDevice:
|
||||
return "Can't use Snapshot on a physical device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
static var cacheDirectory: URL?
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
Snapshot.app = app
|
||||
Snapshot.waitForAnimations = waitForAnimations
|
||||
|
||||
do {
|
||||
let cacheDir = try getCacheDirectory()
|
||||
Snapshot.cacheDirectory = cacheDir
|
||||
setLanguage(app)
|
||||
setLocale(app)
|
||||
setLaunchArguments(app)
|
||||
} catch let error {
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
class func setLanguage(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set language...")
|
||||
}
|
||||
}
|
||||
|
||||
class func setLocale(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
||||
|
||||
do {
|
||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
||||
let results = matches.map { result -> String in
|
||||
(launchArguments as NSString).substring(with: result.range)
|
||||
}
|
||||
app.launchArguments += results
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set launch_arguments...")
|
||||
}
|
||||
}
|
||||
|
||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
if timeout > 0 {
|
||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
||||
}
|
||||
|
||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
||||
|
||||
if Snapshot.waitForAnimations {
|
||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
||||
#else
|
||||
|
||||
guard self.app != nil else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
#endif
|
||||
|
||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
||||
|
||||
do {
|
||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
||||
let range = NSRange(location: 0, length: simulator.count)
|
||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
} catch let error {
|
||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
||||
#if os(watchOS)
|
||||
return image
|
||||
#else
|
||||
if #available(iOS 10.0, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
||||
}
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#endif
|
||||
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
||||
}
|
||||
|
||||
class func getCacheDirectory() throws -> URL {
|
||||
let cachePath = "Library/Caches/tools.fastlane"
|
||||
// on OSX config is stored in /Users/<username>/Library
|
||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
||||
#if os(OSX)
|
||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
||||
}
|
||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#else
|
||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementAttributes {
|
||||
var isNetworkLoadingIndicator: Bool {
|
||||
if hasAllowListedIdentifier { return false }
|
||||
|
||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
||||
|
||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
||||
}
|
||||
|
||||
var hasAllowListedIdentifier: Bool {
|
||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
||||
|
||||
return allowListedIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
||||
if elementType == .statusBar { return true }
|
||||
guard frame.origin == .zero else { return false }
|
||||
|
||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
||||
|
||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementQuery {
|
||||
var networkLoadingIndicators: XCUIElementQuery {
|
||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isNetworkLoadingIndicator
|
||||
}
|
||||
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
}
|
||||
|
||||
let deviceWidth = app.windows.firstMatch.frame.width
|
||||
|
||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isStatusBar(deviceWidth)
|
||||
}
|
||||
|
||||
return self.containing(isStatusBar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
||||
return numberA...numberB ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.30]
|
||||
22
Podfile
Normal file
@@ -0,0 +1,22 @@
|
||||
# Uncomment the next line to define a global platform for your project
|
||||
# platform :ios, '9.0'
|
||||
platform :ios, '17.6'
|
||||
target 'Cable' do
|
||||
# Comment the next line if you don't want to use dynamic frameworks
|
||||
use_frameworks!
|
||||
|
||||
# Pods for Cable
|
||||
target 'CableTests' do
|
||||
inherit! :search_paths
|
||||
# Pods for testing
|
||||
end
|
||||
|
||||
target 'CableUITests' do
|
||||
# Pods for testing
|
||||
end
|
||||
|
||||
target 'CableUITestsScreenshot' do
|
||||
# Pods for testing
|
||||
end
|
||||
|
||||
end
|
||||
37
README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Cable
|
||||
|
||||
Cable is a SwiftUI app for laying out low voltage electrical systems. It helps you size conductors, manage saved loads, and browse the VoltPlan component library so you can assemble accurate bills of materials for off-grid or mobile installs.
|
||||
|
||||
## Requirements
|
||||
- macOS with Xcode 15 or newer
|
||||
- iOS 17 simulator (iPhone 15 target used in CI)
|
||||
- SwiftData and SwiftUI are bundled with the Xcode toolchain; no additional dependencies are required
|
||||
|
||||
## Getting Started
|
||||
- `open Cable.xcodeproj` launches the project with the correct scheme configuration
|
||||
- Alternatively run `xed Cable.xcodeproj` from Terminal to open the project in Xcode
|
||||
- Select the **Cable** scheme and the **iPhone 15** simulator, then press **Run**
|
||||
|
||||
## Build & Test from the Command Line
|
||||
- Build: `xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' build`
|
||||
- Test (unit + UI bundles): `xcodebuild -scheme Cable -destination 'platform=iOS Simulator,name=iPhone 15' test`
|
||||
- Clean derived data if Xcode caches cause issues: `xcodebuild -scheme Cable clean`
|
||||
|
||||
## Project Layout
|
||||
- `Cable/` – SwiftUI source, models, and assets (`CableApp.swift`, `CalculatorView`, `SystemEditorView`, `ComponentLibraryView`, etc.)
|
||||
- `CableTests/` – Focused XCTest coverage for calculator logic, unit conversions, and persistence helpers
|
||||
- `CableUITests/` – UI automation flows that exercise onboarding, load editing, and the component browser
|
||||
- `fastlane/` – Automation scripts for screenshots and releases
|
||||
- `Cable.xcodeproj` – Project configuration targeting iOS 17
|
||||
|
||||
## Core Features
|
||||
- Interactive cable sizing calculator with support for metric and imperial unit systems
|
||||
- SwiftData-backed storage for electrical systems and saved loads
|
||||
- Remote VoltPlan component library with searchable affiliate links
|
||||
- Onboarding flows that guide new users through system setup and load entry
|
||||
|
||||
## Development Tips
|
||||
- Stick to idiomatic Swift style: four-space indentation, trailing commas on multiline collections, and `UpperCamelCase`/`lowerCamelCase` naming
|
||||
- Keep SwiftUI view files focused on a single primary view; extract helpers when logic grows
|
||||
- Run the test targets before opening a pull request to maintain coverage
|
||||
- Commit messages should be short, imperative phrases (e.g., `Update library paging`)
|
||||
BIN
Shots/Fonts/Inter-Bold.otf
Normal file
BIN
Shots/Fonts/Oswald-Light.ttf
Normal file
BIN
Shots/Fonts/Oswald-SemiBold.ttf
Normal file
7
Shots/Titles/de.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
OnboardingSystemsView=Lege*einfach*Systeme an\nund vergleiche sie
|
||||
OnboardingLoadsView=Stelle Geräte*übersichtlich*\ndar und verwalte sie
|
||||
LoadEditorView=Berechne*zuverlässig*\ndie richtige Sicherung
|
||||
ComponentSelectorView=Finde im*großen*Teilekatalog\nwas du suchst
|
||||
SystemsWithSampleData=Navigiere*schnell*\ndurch Deine Systeme
|
||||
AdventureVanLoads=Erstelle*individuelle*\nVerbraucher für Dein System
|
||||
AdventureVanBillOfMaterials=Behalte den*Überblick*\nwelche Teile du schon hast
|
||||
7
Shots/Titles/en.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
OnboardingSystemsView=*Easily*create systems\nand compare them
|
||||
OnboardingLoadsView=*Clearly*list devices\nand manage them
|
||||
LoadEditorView=*Reliably*size the fuse\nfor each device
|
||||
ComponentSelectorView=Find in the*huge*catalog\nwhat you need
|
||||
SystemsWithSampleData=*Quickly*browse your systems\nat a glance
|
||||
AdventureVanLoads=*Create*custom loads\nfor your setup
|
||||
AdventureVanBillOfMaterials=Stay*organized*\nwith your purchases
|
||||
7
Shots/Titles/es.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
OnboardingSystemsView=*Fácil*crear sistemas\ny compararlos
|
||||
OnboardingLoadsView=*Claro*lista equipos\ny gestiona todo
|
||||
LoadEditorView=*Fiable*calcula el fusible\nadecuado
|
||||
ComponentSelectorView=Busca en el*amplio*catálogo\nlo que necesitas
|
||||
SystemsWithSampleData=*Rápido*revisa tus sistemas\nde un vistazo
|
||||
AdventureVanLoads=*Crea*cargas a medida\npara tu sistema
|
||||
AdventureVanBillOfMaterials=Lleva*control*\nde lo ya comprado
|
||||
7
Shots/Titles/fr.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
OnboardingSystemsView=*Facile*créer des systèmes\net les comparer
|
||||
OnboardingLoadsView=*Clairement*voir les appareils\net les gérer
|
||||
LoadEditorView=*Fiable*calcule le fusible\nadapté
|
||||
ComponentSelectorView=Trouve dans le*vaste*catalogue\nce que tu cherches
|
||||
SystemsWithSampleData=*Rapide*parcours tes systèmes\nd'un coup d'œil
|
||||
AdventureVanLoads=*Crée*des charges sur mesure\npour ton système
|
||||
AdventureVanBillOfMaterials=Garde*trace* de tes achats\ndéjà faits
|
||||
7
Shots/Titles/nl.conf
Normal file
@@ -0,0 +1,7 @@
|
||||
OnboardingSystemsView=*Simpel*systemen aanmaken\nen vergelijken
|
||||
OnboardingLoadsView=*Duidelijk*apparaten tonen\nen beheren
|
||||
LoadEditorView=*Betrouwbaar*zekering kiezen\nper apparaat
|
||||
ComponentSelectorView=Vind in de*grote*catalogus\nwat je zoekt
|
||||
SystemsWithSampleData=*Snel*door je systemen\nbladeren
|
||||
AdventureVanLoads=*Maak*aangepaste verbruikers\nvoor je systeem
|
||||
AdventureVanBillOfMaterials=Houd*overzicht*\nvan wat je al kocht
|
||||
BIN
Shots/frame-bg-phone.png
Normal file
|
After Width: | Height: | Size: 754 KiB |
BIN
Shots/frame-bg.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
@@ -1,6 +0,0 @@
|
||||
app_identifier("app.voltplan.CableApp") # The bundle identifier of your app
|
||||
# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username
|
||||
|
||||
|
||||
# For more information about the Appfile, see:
|
||||
# https://docs.fastlane.tools/advanced/#appfile
|
||||
@@ -1,23 +0,0 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
#
|
||||
# For a list of all available actions, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/actions
|
||||
#
|
||||
# For a list of all available plugins, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/plugins/available-plugins
|
||||
#
|
||||
|
||||
# Uncomment the line if you want fastlane to automatically update itself
|
||||
# update_fastlane
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Generate new localized screenshots"
|
||||
lane :screenshots do
|
||||
capture_screenshots(scheme: "CableScreenshots")
|
||||
end
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios screenshots
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios screenshots
|
||||
```
|
||||
|
||||
Generate new localized screenshots
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
@@ -1,54 +0,0 @@
|
||||
# Uncomment the lines below you want to change by removing the # in the beginning
|
||||
devices([
|
||||
"iPhone 17 Pro",
|
||||
"iPhone 17 Pro Max"
|
||||
])
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"nl-NL",
|
||||
"es-ES"
|
||||
])
|
||||
|
||||
scheme("CableScreenshots")
|
||||
clear_previous_screenshots(true)
|
||||
localize_simulator(true)
|
||||
erase_simulator(true)
|
||||
override_status_bar(true)
|
||||
# A list of devices you want to take the screenshots from
|
||||
# devices([
|
||||
# "iPhone 8",
|
||||
# "iPhone 8 Plus",
|
||||
# "iPhone SE",
|
||||
# "iPhone X",
|
||||
# "iPad Pro (12.9-inch)",
|
||||
# "iPad Pro (9.7-inch)",
|
||||
# "Apple TV 1080p",
|
||||
# "Apple Watch Series 6 - 44mm"
|
||||
# ])
|
||||
|
||||
# languages([
|
||||
# "en-US",
|
||||
# "de-DE",
|
||||
# "it-IT",
|
||||
# ["pt", "pt_BR"] # Portuguese with Brazilian locale
|
||||
# ])
|
||||
|
||||
# The name of the scheme which contains the UI Tests
|
||||
# scheme("SchemeName")
|
||||
|
||||
# Where should the resulting screenshots be stored?
|
||||
# output_directory("./screenshots")
|
||||
|
||||
# remove the '#' to clear all previously generated screenshots before creating new ones
|
||||
# clear_previous_screenshots(true)
|
||||
|
||||
# Remove the '#' to set the status bar to 9:41 AM, and show full battery and reception. See also override_status_bar_arguments for custom options.
|
||||
# override_status_bar(true)
|
||||
|
||||
# Arguments to pass to the app on launch. See https://docs.fastlane.tools/actions/snapshot/#launch-arguments
|
||||
# launch_arguments(["-favColor red"])
|
||||
|
||||
# For more information about all available options run
|
||||
# fastlane action snapshot
|
||||
@@ -1,313 +0,0 @@
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
//
|
||||
// Created by Felix Krause on 10/8/15.
|
||||
//
|
||||
|
||||
// -----------------------------------------------------
|
||||
// IMPORTANT: When modifying this file, make sure to
|
||||
// increment the version number at the very
|
||||
// bottom of the file to notify users about
|
||||
// the new SnapshotHelper.swift
|
||||
// -----------------------------------------------------
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
} else {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
case cannotFindSimulatorHomeDirectory
|
||||
case cannotRunOnPhysicalDevice
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .cannotFindSimulatorHomeDirectory:
|
||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
||||
case .cannotRunOnPhysicalDevice:
|
||||
return "Can't use Snapshot on a physical device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
static var cacheDirectory: URL?
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
Snapshot.app = app
|
||||
Snapshot.waitForAnimations = waitForAnimations
|
||||
|
||||
do {
|
||||
let cacheDir = try getCacheDirectory()
|
||||
Snapshot.cacheDirectory = cacheDir
|
||||
setLanguage(app)
|
||||
setLocale(app)
|
||||
setLaunchArguments(app)
|
||||
} catch let error {
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
class func setLanguage(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set language...")
|
||||
}
|
||||
}
|
||||
|
||||
class func setLocale(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
||||
|
||||
do {
|
||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
||||
let results = matches.map { result -> String in
|
||||
(launchArguments as NSString).substring(with: result.range)
|
||||
}
|
||||
app.launchArguments += results
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set launch_arguments...")
|
||||
}
|
||||
}
|
||||
|
||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
if timeout > 0 {
|
||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
||||
}
|
||||
|
||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
||||
|
||||
if Snapshot.waitForAnimations {
|
||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
||||
#else
|
||||
|
||||
guard self.app != nil else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
#endif
|
||||
|
||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
||||
|
||||
do {
|
||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
||||
let range = NSRange(location: 0, length: simulator.count)
|
||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
} catch let error {
|
||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
||||
#if os(watchOS)
|
||||
return image
|
||||
#else
|
||||
if #available(iOS 10.0, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
||||
}
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#endif
|
||||
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
||||
}
|
||||
|
||||
class func getCacheDirectory() throws -> URL {
|
||||
let cachePath = "Library/Caches/tools.fastlane"
|
||||
// on OSX config is stored in /Users/<username>/Library
|
||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
||||
#if os(OSX)
|
||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
||||
}
|
||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#else
|
||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementAttributes {
|
||||
var isNetworkLoadingIndicator: Bool {
|
||||
if hasAllowListedIdentifier { return false }
|
||||
|
||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
||||
|
||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
||||
}
|
||||
|
||||
var hasAllowListedIdentifier: Bool {
|
||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
||||
|
||||
return allowListedIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
||||
if elementType == .statusBar { return true }
|
||||
guard frame.origin == .zero else { return false }
|
||||
|
||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
||||
|
||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementQuery {
|
||||
var networkLoadingIndicators: XCUIElementQuery {
|
||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isNetworkLoadingIndicator
|
||||
}
|
||||
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
}
|
||||
|
||||
let deviceWidth = app.windows.firstMatch.frame.width
|
||||
|
||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isStatusBar(deviceWidth)
|
||||
}
|
||||
|
||||
return self.containing(isStatusBar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
||||
return numberA...numberB ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.30]
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="fastlane.lanes">
|
||||
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000145">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: capture_screenshots" time="392.968167">
|
||||
|
||||
</testcase>
|
||||
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
407
frame_screens.sh
Executable file
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
FONT_COLOR="#3C3C3C" # color for light text
|
||||
FONT_BOLD_COLOR="#B51700" # color for bold text
|
||||
|
||||
ONLY_IPHONE=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
|
||||
|
||||
--iphone-only Only frame screenshots whose device slug is not iPad.
|
||||
SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots)
|
||||
BG_IMAGE Background image to use (default: Shots/frame-bg.png)
|
||||
OUT_ROOT Output folder for framed shots (default: Shots/Framed)
|
||||
EOF
|
||||
}
|
||||
|
||||
POSITIONAL_ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--iphone-only)
|
||||
ONLY_IPHONE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
POSITIONAL_ARGS+=("$@")
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ((${#POSITIONAL_ARGS[@]})); then
|
||||
set -- "${POSITIONAL_ARGS[@]}"
|
||||
else
|
||||
set --
|
||||
fi
|
||||
|
||||
# Inputs
|
||||
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…)
|
||||
BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image
|
||||
OUT_ROOT="${3:-Shots/Framed}" # output folder
|
||||
FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text
|
||||
FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
|
||||
|
||||
# Tweakables
|
||||
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
|
||||
INSET=2 # inset (px) to shave off simulator’s black edge pixels
|
||||
SHADOW_OPACITY=0 # 0–100
|
||||
SHADOW_BLUR=20 # blur radius
|
||||
SHADOW_OFFSET_X=0 # px
|
||||
SHADOW_OFFSET_Y=40 # px
|
||||
CANVAS_MARGIN=245 # default margin around the device on the background, px
|
||||
TITLE_MARGIN=378 # default margin above the device for title text, px
|
||||
TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px
|
||||
|
||||
# Device-specific overrides (can be tuned via env vars)
|
||||
TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}"
|
||||
TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}"
|
||||
TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}"
|
||||
TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}"
|
||||
IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}"
|
||||
IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}"
|
||||
IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}"
|
||||
|
||||
mkdir -p "$OUT_ROOT"
|
||||
|
||||
# Function to render mixed-font text (light + semi-bold for *text*)
|
||||
render_mixed_font_title() {
|
||||
local canvas="$1"
|
||||
local title_text="$2"
|
||||
local title_y="$3"
|
||||
local output="$4"
|
||||
|
||||
local expanded_title
|
||||
expanded_title="$(printf '%b' "$title_text")"
|
||||
|
||||
local temp_img
|
||||
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
|
||||
cp "$canvas" "$temp_img"
|
||||
|
||||
read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")"
|
||||
|
||||
local -a lines=()
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
lines+=("$line")
|
||||
done < <(printf '%s' "$expanded_title")
|
||||
|
||||
if ((${#lines[@]} == 0)); then
|
||||
lines+=("$expanded_title")
|
||||
fi
|
||||
|
||||
if ((${#lines[@]} > 2)); then
|
||||
lines=("${lines[@]:0:2}")
|
||||
fi
|
||||
|
||||
for idx in "${!lines[@]}"; do
|
||||
local line="${lines[$idx]}"
|
||||
local current_y=$((title_y + idx * TITLE_LINE_SPACING))
|
||||
|
||||
local -a text_segments=()
|
||||
local -a font_types=()
|
||||
local current_text=""
|
||||
local in_bold=false
|
||||
local i=0
|
||||
local line_length=${#line}
|
||||
|
||||
while [ $i -lt $line_length ]; do
|
||||
local char="${line:$i:1}"
|
||||
if [[ "$char" == "*" ]]; then
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
else
|
||||
font_types+=("light")
|
||||
fi
|
||||
current_text=""
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
in_bold=false
|
||||
else
|
||||
in_bold=true
|
||||
fi
|
||||
else
|
||||
current_text+="$char"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
else
|
||||
font_types+=("light")
|
||||
fi
|
||||
|
||||
local total_width=0
|
||||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||||
local segment="${text_segments[$j]}"
|
||||
if [[ -n "$segment" ]]; then
|
||||
local font_for_measurement="$FONT"
|
||||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||||
font_for_measurement="$FONT_BOLD"
|
||||
fi
|
||||
local segment_for_measurement="$segment"
|
||||
segment_for_measurement="${segment_for_measurement/#/ }"
|
||||
segment_for_measurement="${segment_for_measurement/%/ }"
|
||||
local part_width
|
||||
part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
|
||||
total_width=$((total_width + part_width))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( total_width <= 0 )); then
|
||||
continue
|
||||
fi
|
||||
|
||||
local start_x=$(( (canvas_w - total_width) / 2 ))
|
||||
local x_offset=0
|
||||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||||
local segment="${text_segments[$j]}"
|
||||
if [[ -n "$segment" ]]; then
|
||||
local font_to_use="$FONT"
|
||||
local color_to_use="$FONT_COLOR"
|
||||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||||
font_to_use="$FONT_BOLD"
|
||||
color_to_use="$FONT_BOLD_COLOR"
|
||||
fi
|
||||
local segment_for_rendering="$segment"
|
||||
segment_for_rendering="${segment_for_rendering/#/ }"
|
||||
segment_for_rendering="${segment_for_rendering/%/ }"
|
||||
magick "$temp_img" \
|
||||
-font "$font_to_use" -pointsize 148 -fill "$color_to_use" \
|
||||
-gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \
|
||||
"$temp_img"
|
||||
local text_width
|
||||
text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
|
||||
x_offset=$((x_offset + text_width))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
cp "$temp_img" "$output"
|
||||
rm -f "$temp_img"
|
||||
}
|
||||
|
||||
# Function to get title from config file
|
||||
get_title() {
|
||||
local lang="$1"
|
||||
local screenshot_name="$2"
|
||||
local config_file="./Shots/Titles/${lang}.conf"
|
||||
|
||||
# Extract view name from filename format: 03-LoadEditorView_0_5EE662BD-84C7-41AC-806E-EB8C7340A037.png
|
||||
# Remove .png extension, then extract the part after the first dash and before the first underscore
|
||||
local base_name=$(basename "$screenshot_name" .png)
|
||||
# Remove leading number and dash (e.g., "03-")
|
||||
base_name=${base_name#*-}
|
||||
# Remove everything from the first underscore onwards (e.g., "_0_5EE662BD...")
|
||||
base_name=${base_name%%_*}
|
||||
|
||||
# Try to find title in config file
|
||||
if [[ -f "$config_file" ]]; then
|
||||
local title=$(grep "^${base_name}=" "$config_file" 2>/dev/null | cut -d'=' -f2-)
|
||||
if [[ -n "$title" ]]; then
|
||||
echo "$title"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to default title
|
||||
echo "***NOT SET***"
|
||||
}
|
||||
|
||||
# Function to frame one screenshot
|
||||
frame_one () {
|
||||
local in="$1" # input screenshot (e.g., 1320x2868)
|
||||
local out="$2" # output image
|
||||
local bg="$3"
|
||||
local lang="$4" # language code (e.g., "de", "en")
|
||||
local screenshot_name="$5" # screenshot filename
|
||||
local target_width="$6"
|
||||
local target_height="$7"
|
||||
local canvas_margin="$8"
|
||||
local title_margin="$9"
|
||||
|
||||
# Read sizes
|
||||
read -r W H <<<"$(identify -format "%w %h" "$in")"
|
||||
|
||||
# Determine corner radius
|
||||
local R
|
||||
if [[ "$CORNER_RADIUS" == "auto" ]]; then
|
||||
# Heuristic: ~1/12 of width works well for iPhone 6.9" (≈110px for 1320px width)
|
||||
R=$(( W / 12 ))
|
||||
else
|
||||
R=$CORNER_RADIUS
|
||||
fi
|
||||
|
||||
# Create rounded-corner mask the same size as the screenshot
|
||||
local mask
|
||||
mask="$(mktemp /tmp/mask.XXXXXX_$$.png)"
|
||||
magick -size "${W}x${H}" xc:black \
|
||||
-fill white -draw "roundrectangle ${INSET},${INSET},$((W-1-INSET)),$((H-1-INSET)),$R,$R" \
|
||||
"$mask"
|
||||
|
||||
# Apply rounded corners + make a soft drop shadow
|
||||
# 1) Rounded PNG
|
||||
local rounded
|
||||
rounded="$(mktemp /tmp/rounded.XXXXXX_$$.png)"
|
||||
magick "$in" -alpha set "$mask" -compose copyopacity -composite "$rounded"
|
||||
|
||||
# 2) Shadow from rounded image
|
||||
local shadow
|
||||
shadow="$(mktemp /tmp/shadow.XXXXXX_$$.png)"
|
||||
magick "$rounded" \
|
||||
\( +clone -background black -shadow ${SHADOW_OPACITY}x${SHADOW_BLUR}+${SHADOW_OFFSET_X}+${SHADOW_OFFSET_Y} \) \
|
||||
+swap -background none -layers merge +repage "$shadow"
|
||||
|
||||
# Compose on the background, centered
|
||||
# First, scale background to be at least screenshot+margin in both dimensions
|
||||
read -r BW BH <<<"$(identify -format "%w %h" "$bg")"
|
||||
local minW=$((W + 2*canvas_margin))
|
||||
local minH=$((H + 2*canvas_margin + title_margin))
|
||||
local canvas
|
||||
canvas="$(mktemp /tmp/canvas.XXXXXX_$$.png)"
|
||||
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
|
||||
|
||||
# Add title text above the screenshot
|
||||
local title_text=$(get_title "$lang" "$screenshot_name")
|
||||
local with_title
|
||||
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
|
||||
|
||||
# Calculate title position (center horizontally, positioned above the screenshot)
|
||||
local title_y=$((title_margin - 100)) # 10px from top of title margin
|
||||
|
||||
# Render title with mixed fonts
|
||||
render_mixed_font_title "$canvas" "$title_text" "$title_y" "$with_title"
|
||||
|
||||
# Now place shadow (which already includes the rounded image) positioned below the title
|
||||
# Calculate the vertical offset to center the screenshot in the remaining space below the title
|
||||
local screenshot_offset=$((title_margin*2))
|
||||
local temp_result
|
||||
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
|
||||
magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result"
|
||||
|
||||
# Final step: scale to exact dimensions 1320 × 2868px
|
||||
magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out"
|
||||
|
||||
rm -f "$mask" "$rounded" "$shadow" "$canvas" "$with_title" "$temp_result"
|
||||
}
|
||||
|
||||
# Process all screenshots in SRC_ROOT/*/*.png
|
||||
resolve_device_profile() {
|
||||
local device_slug="$1"
|
||||
|
||||
PROFILE_BG="$BG_IMAGE"
|
||||
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_PHONE"
|
||||
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE"
|
||||
PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN"
|
||||
PROFILE_TITLE_MARGIN="$TITLE_MARGIN"
|
||||
|
||||
if [[ -n "$device_slug" ]]; then
|
||||
local slug_lower
|
||||
slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$slug_lower" == *"ipad"* ]]; then
|
||||
PROFILE_BG="$IPAD_BG_IMAGE"
|
||||
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_IPAD"
|
||||
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD"
|
||||
PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN"
|
||||
PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
process_lang_dir() {
|
||||
local lang_path="$1"
|
||||
local lang="$2"
|
||||
local device_slug="$3"
|
||||
|
||||
local out_dir="$OUT_ROOT"
|
||||
local log_prefix="$lang"
|
||||
|
||||
if [[ -n "$device_slug" ]]; then
|
||||
out_dir="$out_dir/$device_slug"
|
||||
log_prefix="$device_slug/$lang"
|
||||
fi
|
||||
|
||||
out_dir="$out_dir/$lang"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
resolve_device_profile "$device_slug"
|
||||
|
||||
shopt -s nullglob
|
||||
for shot in "$lang_path"/*.png; do
|
||||
local base="$(basename "$shot")"
|
||||
frame_one \
|
||||
"$shot" \
|
||||
"$out_dir/$base" \
|
||||
"$PROFILE_BG" \
|
||||
"$lang" \
|
||||
"$base" \
|
||||
"$PROFILE_TARGET_WIDTH" \
|
||||
"$PROFILE_TARGET_HEIGHT" \
|
||||
"$PROFILE_CANVAS_MARGIN" \
|
||||
"$PROFILE_TITLE_MARGIN"
|
||||
echo "Framed: $log_prefix/$base"
|
||||
done
|
||||
}
|
||||
|
||||
shopt -s nullglob
|
||||
found_any=false
|
||||
skipped_for_device=false
|
||||
for entry in "$SRC_ROOT"/*; do
|
||||
[[ -d "$entry" ]] || continue
|
||||
entry_basename="$(basename "$entry")"
|
||||
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then
|
||||
skipped_for_device=true
|
||||
continue
|
||||
fi
|
||||
|
||||
pattern="${entry%/}/*.png"
|
||||
if compgen -G "$pattern" > /dev/null; then
|
||||
process_lang_dir "$entry" "$(basename "$entry")" ""
|
||||
found_any=true
|
||||
continue
|
||||
fi
|
||||
|
||||
for langdir in "$entry"/*; do
|
||||
[[ -d "$langdir" ]] || continue
|
||||
if [[ "$ONLY_IPHONE" == true ]]; then
|
||||
lang_device_slug="$(basename "$entry")"
|
||||
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$lang_slug_lower" == *"ipad"* ]]; then
|
||||
skipped_for_device=true
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
pattern="${langdir%/}/*.png"
|
||||
if compgen -G "$pattern" > /dev/null; then
|
||||
process_lang_dir "$langdir" "$(basename "$langdir")" "$(basename "$entry")"
|
||||
found_any=true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ "$found_any" == false ]]; then
|
||||
if [[ "$ONLY_IPHONE" == true && "$skipped_for_device" == true ]]; then
|
||||
echo "No iPhone screenshots found under $SRC_ROOT" >&2
|
||||
else
|
||||
echo "No screenshots found under $SRC_ROOT" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Done. Framed images in: $OUT_ROOT/"
|
||||
102
shooter.sh
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCHEME="CableScreenshots"
|
||||
RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
|
||||
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
|
||||
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
|
||||
|
||||
is_truthy() {
|
||||
case "$1" in
|
||||
1|true|TRUE|yes|YES|on|ON) return 0 ;;
|
||||
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
DEVICE_MATRIX=(
|
||||
# "iPhone 17 Pro Max|26.0|iphone-17-pro-max"
|
||||
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
|
||||
)
|
||||
|
||||
command -v xcparse >/dev/null 2>&1 || {
|
||||
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
|
||||
resolve_udid() {
|
||||
local name="$1"; local os="$2"
|
||||
if [[ -n "$os" ]]; then
|
||||
# Prefer Shutdown state for a clean start
|
||||
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
|
||||
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
|
||||
else
|
||||
xcrun simctl list devices | awk -v n="$name" -F '[()]' \
|
||||
'$0 ~ n && /Shutdown/ {print $2; exit}'
|
||||
fi
|
||||
}
|
||||
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
|
||||
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
|
||||
|
||||
for lang in de fr en es nl; do
|
||||
echo "Resetting simulator for a clean start..."
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
fi
|
||||
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
if is_truthy "$RESET_SIMULATOR"; then
|
||||
xcrun simctl erase "$UDID"
|
||||
else
|
||||
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
|
||||
if [[ -n "$bundle" ]]; then
|
||||
xcrun simctl terminate "$UDID" "$bundle" || true
|
||||
xcrun simctl uninstall "$UDID" "$bundle" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo "Running screenshots for $lang"
|
||||
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
|
||||
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
fi
|
||||
|
||||
xcrun simctl boot "$UDID" || true
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
xcrun simctl boot "$UDID"
|
||||
xcrun simctl status_bar booted override \
|
||||
--time "9:41" \
|
||||
--batteryState charged --batteryLevel 100 \
|
||||
--wifiBars 3
|
||||
|
||||
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
|
||||
|
||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
||||
rm -rf "$bundle" "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
|
||||
xcodebuild test \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$UDID" \
|
||||
-resultBundlePath "$bundle"
|
||||
|
||||
xcparse screenshots "$bundle" "$outdir"
|
||||
echo "Exported screenshots to $outdir"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
done
|
||||
done
|
||||