Compare commits

...

30 Commits

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

2
.bundle/config Normal file
View File

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

6
.gitignore vendored
View File

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

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
3.2.4

View File

@@ -45,11 +45,21 @@
);
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
};
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
CableUITestsScreenshotLaunchTests.swift,
);
target = 3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */,
);
path = CableUITestsScreenshot;
sourceTree = "<group>";
};
@@ -113,6 +123,7 @@
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
3E5C0BCD2E72C0FD00247EC8 /* Products */,
57738E9B07763CFA62681EEE /* Pods */,
);
sourceTree = "<group>";
};
@@ -127,6 +138,13 @@
name = Products;
sourceTree = "<group>";
};
57738E9B07763CFA62681EEE /* Pods */ = {
isa = PBXGroup;
children = (
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -147,8 +165,6 @@
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
);
name = CableUITestsScreenshot;
packageProductDependencies = (
);
productName = CableUITestsScreenshot;
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -169,8 +185,6 @@
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
);
name = Cable;
packageProductDependencies = (
);
productName = Cable;
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
productType = "com.apple.product-type.application";
@@ -192,8 +206,6 @@
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
);
name = CableTests;
packageProductDependencies = (
);
productName = CableTests;
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
@@ -215,8 +227,6 @@
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
);
name = CableUITests;
packageProductDependencies = (
);
productName = CableUITests;
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
@@ -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 = 5;
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.21;
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 = 5;
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.21;
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;

View File

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

36
Cable/AppDelegate.swift Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -2,6 +2,77 @@
"affiliate.description.with_link" = "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.";
"affiliate.description.without_link" = "Tapping above shows a full bill of materials with shopping searches to help you source parts.";
"affiliate.disclaimer" = "Purchases through affiliate links may support VoltPlan.";
"battery.bank.badge.capacity" = "Capacity";
"battery.bank.badge.energy" = "Energy";
"battery.bank.badge.voltage" = "Voltage";
"battery.bank.banner.capacity" = "Capacity mismatch detected";
"battery.bank.banner.voltage" = "Voltage mismatch detected";
"battery.bank.empty.subtitle" = "Tap the plus button to configure a battery for %@.";
"battery.bank.empty.title" = "No Batteries Yet";
"battery.bank.header.title" = "Battery Bank";
"battery.bank.metric.capacity" = "Capacity";
"battery.bank.metric.count" = "Batteries";
"battery.bank.metric.energy" = "Energy";
"battery.bank.metric.usable_capacity" = "Usable Capacity";
"battery.bank.metric.usable_energy" = "Usable Energy";
"battery.bank.status.capacity.message" = "%@ uses a different capacity than the dominant bank size of %@. Mismatched capacities cause uneven discharge and premature wear.";
"battery.bank.status.capacity.title" = "Capacity mismatch";
"battery.bank.status.dismiss" = "Got it";
"battery.bank.status.multiple.batteries" = "%d batteries";
"battery.bank.status.single.battery" = "One battery";
"battery.bank.status.voltage.message" = "%@ diverges from the bank baseline of %@. Mixing nominal voltages leads to uneven charging and can damage connected chargers or inverters.";
"battery.bank.status.voltage.title" = "Voltage mismatch";
"battery.bank.warning.capacity.short" = "Capacity";
"battery.bank.warning.voltage.short" = "Voltage";
"battery.editor.advanced.usable_capacity.footer_default" = "Defaults to %@ based on chemistry.";
"battery.editor.advanced.usable_capacity.footer_override" = "Override active. Chemistry default remains %@.";
"battery.editor.advanced.charge_voltage.helper" = "Set the maximum recommended charging voltage.";
"battery.editor.advanced.cutoff_voltage.helper" = "Set the minimum safe discharge voltage.";
"battery.editor.advanced.temperature_range.helper" = "Define the recommended operating temperature range.";
"battery.editor.alert.charge_voltage.message" = "Enter charge voltage in volts (V)";
"battery.editor.alert.charge_voltage.placeholder" = "Charge Voltage";
"battery.editor.alert.charge_voltage.title" = "Edit Charge Voltage";
"battery.editor.alert.cutoff_voltage.message" = "Enter cut-off voltage in volts (V)";
"battery.editor.alert.cutoff_voltage.placeholder" = "Cut-off Voltage";
"battery.editor.alert.cutoff_voltage.title" = "Edit Cut-off Voltage";
"battery.editor.alert.maximum_temperature.message" = "Enter maximum temperature in degrees Celsius (\u00B0C)";
"battery.editor.alert.maximum_temperature.placeholder" = "Maximum Temperature (\u00B0C)";
"battery.editor.alert.maximum_temperature.title" = "Edit Maximum Temperature";
"battery.editor.alert.minimum_temperature.message" = "Enter minimum temperature in degrees Celsius (\u00B0C)";
"battery.editor.alert.minimum_temperature.placeholder" = "Minimum Temperature (\u00B0C)";
"battery.editor.alert.minimum_temperature.title" = "Edit Minimum Temperature";
"battery.editor.alert.cancel" = "Cancel";
"battery.editor.alert.capacity.message" = "Enter capacity in amp-hours (Ah)";
"battery.editor.alert.capacity.placeholder" = "Capacity";
"battery.editor.alert.capacity.title" = "Edit Capacity";
"battery.editor.alert.save" = "Save";
"battery.editor.alert.usable_capacity.message" = "Enter usable capacity percentage (%)";
"battery.editor.alert.usable_capacity.placeholder" = "Usable Capacity (%)";
"battery.editor.alert.usable_capacity.title" = "Edit Usable Capacity";
"battery.editor.alert.voltage.message" = "Enter voltage in volts (V)";
"battery.editor.alert.voltage.placeholder" = "Voltage";
"battery.editor.alert.voltage.title" = "Edit Nominal Voltage";
"battery.editor.button.reset_default" = "Reset";
"battery.editor.cancel" = "Cancel";
"battery.editor.default_name" = "New Battery";
"battery.editor.field.chemistry" = "Chemistry";
"battery.editor.field.name" = "Name";
"battery.editor.placeholder.name" = "House Bank";
"battery.editor.save" = "Save";
"battery.editor.section.advanced" = "Advanced";
"battery.editor.section.summary" = "Summary";
"battery.editor.slider.capacity" = "Capacity";
"battery.editor.slider.charge_voltage" = "Charge Voltage";
"battery.editor.slider.cutoff_voltage" = "Cut-off Voltage";
"battery.editor.slider.temperature_range" = "Temperature Range";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.usable_capacity" = "Usable Capacity (%)";
"battery.editor.slider.voltage" = "Nominal Voltage";
"battery.editor.title" = "Battery Setup";
"battery.onboarding.subtitle" = "Track your bank's capacity and chemistry to keep runtime expectations in check.";
"battery.onboarding.title" = "Add your first battery";
"battery.overview.empty.create" = "Add Battery";
"bom.accessibility.mark.complete" = "Mark %@ complete";
"bom.accessibility.mark.incomplete" = "Mark %@ incomplete";
"bom.fuse.detail" = "Inline holder and %dA fuse";
@@ -13,11 +84,93 @@
"bom.navigation.title.system" = "BOM %@";
"bom.size.unknown" = "Size TBD";
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
"bom.empty.message" = "No components saved in this system yet.";
"bom.export.pdf.button" = "Export PDF";
"bom.export.pdf.error.title" = "Export Failed";
"bom.export.pdf.error.empty" = "Add at least one component before exporting.";
"bom.pdf.header.title" = "System Bill of Materials";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Unit System: %@";
"bom.pdf.placeholder.empty" = "No components available.";
"bom.pdf.page.number" = "Page %d";
"bom.category.components.title" = "Components & Chargers";
"bom.category.components.subtitle" = "Primary devices, controllers, and charging gear.";
"bom.category.batteries.title" = "Batteries";
"bom.category.batteries.subtitle" = "House banks and storage.";
"bom.category.cables.title" = "Cables";
"bom.category.cables.subtitle" = "Sized power runs for every circuit.";
"bom.category.fuses.title" = "Fuses";
"bom.category.fuses.subtitle" = "Circuit protection and holders.";
"bom.category.accessories.title" = "Accessories";
"bom.category.accessories.subtitle" = "Fuses, lugs, and supporting hardware.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"cable.pro.privacy.label" = "Privacy";
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
"cable.pro.terms.label" = "Terms";
"cable.pro.terms.url" = "https://voltplan.app/terms";
"calculator.advanced.duty_cycle.helper" = "Percentage of each active session where the load actually draws power.";
"calculator.advanced.duty_cycle.title" = "Duty Cycle";
"calculator.advanced.section.title" = "Advanced Settings";
"calculator.advanced.usage_hours.helper" = "Hours per day the load is turned on.";
"calculator.advanced.usage_hours.title" = "Daily On-Time";
"calculator.advanced.usage_hours.unit" = "h/day";
"calculator.alert.duty_cycle.message" = "Enter duty cycle as a percentage (0-100%).";
"calculator.alert.duty_cycle.placeholder" = "Duty Cycle";
"calculator.alert.duty_cycle.title" = "Edit Duty Cycle";
"calculator.alert.usage_hours.message" = "Enter the number of hours per day the load is active.";
"calculator.alert.usage_hours.placeholder" = "Daily On-Time";
"calculator.alert.usage_hours.title" = "Edit Daily On-Time";
"charger.default.new" = "New Charger";
"charger.editor.alert.cancel" = "Cancel";
"charger.editor.alert.current.message" = "Enter current in amps (A)";
"charger.editor.alert.current.title" = "Edit Charge Current";
"charger.editor.alert.input_voltage.title" = "Edit Input Voltage";
"charger.editor.alert.output_voltage.title" = "Edit Output Voltage";
"charger.editor.alert.power.message" = "Enter power in watts (W)";
"charger.editor.alert.power.placeholder" = "Power";
"charger.editor.alert.power.title" = "Edit Charge Power";
"charger.editor.alert.save" = "Save";
"charger.editor.alert.voltage.message" = "Enter voltage in volts (V)";
"charger.editor.appearance.accessibility" = "Edit charger appearance";
"charger.editor.appearance.subtitle" = "Customize how this charger shows up";
"charger.editor.appearance.title" = "Charger Appearance";
"charger.editor.default_name" = "New Charger";
"charger.editor.field.current" = "Charge Current";
"charger.editor.field.input_voltage" = "Input Voltage";
"charger.editor.field.name" = "Name";
"charger.editor.field.output_voltage" = "Output Voltage";
"charger.editor.field.power" = "Charge Power";
"charger.editor.field.power.footer" = "Leave blank when the rated wattage isn't published. We'll calculate it from voltage and current.";
"charger.editor.placeholder.name" = "Workshop Charger";
"charger.editor.section.electrical" = "Electrical";
"charger.editor.section.power" = "Charge Output";
"charger.editor.title" = "Charger";
"chargers.badge.current" = "Current";
"chargers.badge.input" = "Input";
"chargers.badge.output" = "Output";
"chargers.badge.power" = "Power";
"chargers.onboarding.primary" = "Create Charger";
"chargers.onboarding.subtitle" = "Track shore power supplies, alternator chargers, and solar controllers to understand your charging capacity.";
"chargers.onboarding.title" = "Add your chargers";
"chargers.subtitle" = "Charger components will be available soon.";
"chargers.summary.metric.count" = "Chargers";
"chargers.summary.metric.current" = "Charge Rate";
"chargers.summary.metric.output" = "Output Voltage";
"chargers.summary.metric.power" = "Charge Power";
"chargers.summary.title" = "Charging Overview";
"chargers.title" = "Chargers for %@";
"component.fallback.name" = "Component";
"default.load.library" = "Library Load";
"default.load.name" = "My Load";
"default.load.unnamed" = "Unnamed Load";
"default.load.new" = "New Load";
"default.load.unnamed" = "Unnamed Load";
"default.system.name" = "My System";
"default.system.new" = "New System";
"editor.load.name_field" = "Load name";
@@ -26,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";

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -10,18 +10,20 @@ import SwiftData
@main
struct CableApp: App {
@StateObject private var unitSettings = UnitSystemSettings()
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var unitSettings: UnitSystemSettings
@StateObject private var storeKitManager: StoreKitManager
var sharedModelContainer: ModelContainer = {
do {
// Try the simple approach first
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, Item.self)
return try ModelContainer(for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self)
} catch {
print("Failed to create ModelContainer with simple approach: \(error)")
// Try in-memory as fallback
do {
let schema = Schema([ElectricalSystem.self, SavedLoad.self, Item.self])
let schema = Schema([ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self])
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
return try ModelContainer(for: schema, configurations: [modelConfiguration])
} catch {
@@ -30,10 +32,20 @@ struct CableApp: App {
}
}()
init() {
let unitSettings = UnitSystemSettings()
_unitSettings = StateObject(wrappedValue: unitSettings)
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
#if DEBUG
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
#endif
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(unitSettings)
.environmentObject(storeKitManager)
}
.modelContainer(sharedModelContainer)
}

View File

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

View File

@@ -0,0 +1,67 @@
import Foundation
import SwiftData
struct ChargerConfiguration: Identifiable, Hashable {
let id: UUID
var name: String
var inputVoltage: Double
var outputVoltage: Double
var maxCurrentAmps: Double
var maxPowerWatts: Double
var iconName: String
var colorName: String
var system: ElectricalSystem
init(
id: UUID = UUID(),
name: String,
inputVoltage: Double = 230.0,
outputVoltage: Double = 14.2,
maxCurrentAmps: Double = 30.0,
maxPowerWatts: Double = 0.0,
iconName: String = "bolt.fill",
colorName: String = "orange",
system: ElectricalSystem
) {
self.id = id
self.name = name
self.inputVoltage = inputVoltage
self.outputVoltage = outputVoltage
self.maxCurrentAmps = maxCurrentAmps
self.maxPowerWatts = maxPowerWatts
self.iconName = iconName
self.colorName = colorName
self.system = system
}
init(savedCharger: SavedCharger, system: ElectricalSystem) {
self.id = savedCharger.id
self.name = savedCharger.name
self.inputVoltage = savedCharger.inputVoltage
self.outputVoltage = savedCharger.outputVoltage
self.maxCurrentAmps = savedCharger.maxCurrentAmps
self.maxPowerWatts = savedCharger.maxPowerWatts
self.iconName = savedCharger.iconName
self.colorName = savedCharger.colorName
self.system = system
}
var effectivePowerWatts: Double {
if maxPowerWatts > 0 {
return maxPowerWatts
}
return outputVoltage * maxCurrentAmps
}
func apply(to savedCharger: SavedCharger) {
savedCharger.name = name
savedCharger.inputVoltage = inputVoltage
savedCharger.outputVoltage = outputVoltage
savedCharger.maxCurrentAmps = maxCurrentAmps
savedCharger.maxPowerWatts = maxPowerWatts
savedCharger.iconName = iconName
savedCharger.colorName = colorName
savedCharger.system = system
savedCharger.timestamp = Date()
}
}

View File

@@ -0,0 +1,952 @@
import SwiftUI
struct ChargerEditorView: View {
@State private var configuration: ChargerConfiguration
@State private var editingField: EditingField?
@State private var inputVoltageInput: String = ""
@State private var outputVoltageInput: String = ""
@State private var currentInput: String = ""
@State private var powerInput: String = ""
@State private var powerEntryMode: PowerEntryMode
@State private var lastManualPowerWatts: Double
@State private var showingAppearanceEditor = false
let onSave: (ChargerConfiguration) -> Void
private enum EditingField {
case inputVoltage
case outputVoltage
case current
case power
}
private enum PowerEntryMode {
case current
case power
}
private let inputVoltageSnapValues: [Double] = [12, 24, 48, 120, 230, 240]
private let outputVoltageSnapValues: [Double] = [12, 12.6, 12.8, 14.2, 24, 48]
private let currentSnapValues: [Double] = [5, 10, 15, 20, 25, 30, 40, 50, 60, 80, 100, 150, 200]
private let powerSnapValues: [Double] = [100, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000, 2500, 3000]
private let inputVoltageSnapTolerance: Double = 2.0
private let outputVoltageSnapTolerance: Double = 0.5
private let currentSnapTolerance: Double = 2.0
private let powerSnapTolerance: Double = 25.0
private let chargerIconOptions: [String] = [
"bolt.fill",
"bolt",
"bolt.circle",
"bolt.circle.fill",
"bolt.horizontal.circle",
"bolt.square",
"bolt.square.fill",
"bolt.badge.clock",
"bolt.badge.a",
"powerplug",
"flashlight.on.fill",
"battery.100.bolt"
]
private var nameFieldLabel: String {
String(
localized: "charger.editor.field.name",
bundle: .main,
comment: "Label for the charger name text field"
)
}
private var namePlaceholder: String {
String(
localized: "charger.editor.placeholder.name",
bundle: .main,
comment: "Placeholder example for the charger name field"
)
}
private var electricalSectionLabel: String {
String(
localized: "charger.editor.section.electrical",
bundle: .main,
comment: "Label for the electrical section"
)
}
private var chargingSectionLabel: String {
String(
localized: "charger.editor.section.power",
bundle: .main,
comment: "Label for the charging output section"
)
}
private var inputVoltageLabel: String {
String(
localized: "charger.editor.field.input_voltage",
bundle: .main,
comment: "Label for the input voltage slider"
)
}
private var outputVoltageLabel: String {
String(
localized: "charger.editor.field.output_voltage",
bundle: .main,
comment: "Label for the output voltage slider"
)
}
private var currentLabel: String {
String(
localized: "charger.editor.field.current",
bundle: .main,
comment: "Label for the charging current slider"
)
}
private var powerLabel: String {
String(
localized: "charger.editor.field.power",
bundle: .main,
comment: "Label for the optional power field"
)
}
private var powerFooter: String {
String(
localized: "charger.editor.field.power.footer",
bundle: .main,
comment: "Footer text describing how the optional power field works"
)
}
private var appearanceEditorTitle: String {
NSLocalizedString(
"charger.editor.appearance.title",
bundle: .main,
value: "Charger Appearance",
comment: "Title for the charger appearance editor"
)
}
private var appearanceEditorSubtitle: String {
NSLocalizedString(
"charger.editor.appearance.subtitle",
bundle: .main,
value: "Customize how this charger shows up",
comment: "Subtitle shown in the charger appearance editor preview"
)
}
private var appearanceAccessibilityLabel: String {
NSLocalizedString(
"charger.editor.appearance.accessibility",
bundle: .main,
value: "Edit charger appearance",
comment: "Accessibility label for the charger appearance editor button"
)
}
private var inputBadgeLabel: String {
String(
localized: "chargers.badge.input",
bundle: .main,
comment: "Label for input voltage badges"
)
}
private var outputBadgeLabel: String {
String(
localized: "chargers.badge.output",
bundle: .main,
comment: "Label for output voltage badges"
)
}
private var currentBadgeLabel: String {
String(
localized: "chargers.badge.current",
bundle: .main,
comment: "Label for charging current badges"
)
}
private var powerBadgeLabel: String {
String(
localized: "chargers.badge.power",
bundle: .main,
comment: "Label for charging power badges"
)
}
private var wattButtonTitle: String {
String(
localized: "slider.button.watt",
bundle: .main,
comment: "Button label when switching to power entry mode"
)
}
private var ampButtonTitle: String {
String(
localized: "slider.button.ampere",
bundle: .main,
comment: "Button label when switching to current entry mode"
)
}
private var inputVoltageAlertTitle: String {
NSLocalizedString(
"charger.editor.alert.input_voltage.title",
bundle: .main,
value: "Edit Input Voltage",
comment: "Title for the input voltage edit alert"
)
}
private var outputVoltageAlertTitle: String {
NSLocalizedString(
"charger.editor.alert.output_voltage.title",
bundle: .main,
value: "Edit Output Voltage",
comment: "Title for the output voltage edit alert"
)
}
private var currentAlertTitle: String {
NSLocalizedString(
"charger.editor.alert.current.title",
bundle: .main,
value: "Edit Charge Current",
comment: "Title for the charging current edit alert"
)
}
private var powerAlertTitle: String {
NSLocalizedString(
"charger.editor.alert.power.title",
bundle: .main,
value: "Edit Charge Power",
comment: "Title for the power edit alert"
)
}
private var alertMessageVoltage: String {
NSLocalizedString(
"charger.editor.alert.voltage.message",
bundle: .main,
value: "Enter voltage in volts (V)",
comment: "Message for voltage edit alerts"
)
}
private var alertMessagePower: String {
NSLocalizedString(
"charger.editor.alert.power.message",
bundle: .main,
value: "Enter power in watts (W)",
comment: "Message for the power edit alert"
)
}
private var alertMessageCurrent: String {
NSLocalizedString(
"charger.editor.alert.current.message",
bundle: .main,
value: "Enter current in amps (A)",
comment: "Message for the current edit alert"
)
}
private var alertCancelTitle: String {
NSLocalizedString(
"charger.editor.alert.cancel",
bundle: .main,
value: "Cancel",
comment: "Title for cancel buttons in edit alerts"
)
}
private var alertSaveTitle: String {
NSLocalizedString(
"charger.editor.alert.save",
bundle: .main,
value: "Save",
comment: "Title for save buttons in edit alerts"
)
}
private var powerAlertPlaceholder: String {
NSLocalizedString(
"charger.editor.alert.power.placeholder",
bundle: .main,
value: "Power",
comment: "Placeholder for the power edit alert"
)
}
private var iconColor: Color {
Color.componentColor(named: configuration.colorName)
}
private var displayName: String {
configuration.name.isEmpty ? namePlaceholder : configuration.name
}
private var inputVoltageSliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(12, configuration.inputVoltage))
let upperBound = max(300, configuration.inputVoltage)
return lowerBound...upperBound
}
private var outputVoltageSliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(10, configuration.outputVoltage))
let upperBound = max(80, configuration.outputVoltage)
return lowerBound...upperBound
}
private var currentSliderRange: ClosedRange<Double> {
let lowerBound = max(0, min(5, configuration.maxCurrentAmps))
let upperBound = max(200, configuration.maxCurrentAmps)
return lowerBound...upperBound
}
private var powerSliderRange: ClosedRange<Double> {
let effectivePower = configuration.effectivePowerWatts
let upperBound = max(3000, max(configuration.maxPowerWatts, effectivePower))
return 0...upperBound
}
init(configuration: ChargerConfiguration, onSave: @escaping (ChargerConfiguration) -> Void) {
var adjustedConfiguration = configuration
if configuration.maxPowerWatts > 0 {
let voltage = max(configuration.outputVoltage, 0.1)
let derivedCurrent = configuration.maxPowerWatts / voltage
let roundedCurrent = max(0, (derivedCurrent * 10).rounded() / 10)
adjustedConfiguration.maxCurrentAmps = roundedCurrent
}
_configuration = State(initialValue: adjustedConfiguration)
_powerEntryMode = State(initialValue: adjustedConfiguration.maxPowerWatts > 0 ? .power : .current)
let initialPowerCandidate = adjustedConfiguration.maxPowerWatts > 0
? adjustedConfiguration.maxPowerWatts
: max(0, adjustedConfiguration.outputVoltage * adjustedConfiguration.maxCurrentAmps)
let roundedInitialPower = max(0, (initialPowerCandidate / 5).rounded() * 5)
let snapValues = [100.0, 150.0, 200.0, 250.0, 300.0, 400.0, 500.0, 600.0, 750.0, 1000.0, 1250.0, 1500.0, 2000.0, 2500.0, 3000.0]
let closestSnap = snapValues.min { abs($0 - roundedInitialPower) < abs($1 - roundedInitialPower) }
let normalizedInitialPower: Double
if let closestSnap, abs(closestSnap - roundedInitialPower) <= 25.0 {
normalizedInitialPower = closestSnap
} else {
normalizedInitialPower = roundedInitialPower
}
_lastManualPowerWatts = State(initialValue: normalizedInitialPower)
self.onSave = onSave
}
var body: some View {
VStack(spacing: 0) {
headerInfoBar
List {
electricalSection
chargingSection
}
.listStyle(.plain)
.scrollIndicators(.hidden)
.scrollContentBackground(.hidden)
.background(Color.clear)
}
.background(Color(.systemGroupedBackground).ignoresSafeArea())
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
navigationTitleView
}
}
.onDisappear {
onSave(configuration)
}
.sheet(isPresented: $showingAppearanceEditor) {
ItemEditorView(
title: appearanceEditorTitle,
nameFieldLabel: nameFieldLabel,
previewSubtitle: appearanceEditorSubtitle,
icons: chargerIconOptions,
name: Binding(
get: { configuration.name },
set: { configuration.name = $0 }
),
iconName: Binding(
get: { configuration.iconName },
set: { configuration.iconName = $0 }
),
colorName: Binding(
get: { configuration.colorName },
set: { configuration.colorName = $0 }
)
)
}
.alert(
inputVoltageAlertTitle,
isPresented: Binding(
get: { editingField == .inputVoltage },
set: { isPresented in
if !isPresented {
editingField = nil
inputVoltageInput = ""
}
}
)
) {
TextField(
inputVoltageLabel,
text: $inputVoltageInput
)
.keyboardType(.decimalPad)
.onAppear {
if inputVoltageInput.isEmpty {
inputVoltageInput = formattedEditValue(configuration.inputVoltage)
}
}
.onChange(of: inputVoltageInput) { _, newValue in
guard editingField == .inputVoltage, let parsed = parseInput(newValue) else { return }
configuration.inputVoltage = roundToTenth(parsed)
}
Button(alertCancelTitle, role: .cancel) {
editingField = nil
inputVoltageInput = ""
}
Button(alertSaveTitle) {
if let parsed = parseInput(inputVoltageInput) {
configuration.inputVoltage = roundToTenth(parsed)
}
editingField = nil
inputVoltageInput = ""
}
} message: {
Text(alertMessageVoltage)
}
.alert(
outputVoltageAlertTitle,
isPresented: Binding(
get: { editingField == .outputVoltage },
set: { isPresented in
if !isPresented {
editingField = nil
outputVoltageInput = ""
}
}
)
) {
TextField(
outputVoltageLabel,
text: $outputVoltageInput
)
.keyboardType(.decimalPad)
.onAppear {
if outputVoltageInput.isEmpty {
outputVoltageInput = formattedEditValue(configuration.outputVoltage)
}
}
.onChange(of: outputVoltageInput) { _, newValue in
guard editingField == .outputVoltage, let parsed = parseInput(newValue) else { return }
configuration.outputVoltage = roundToTenth(parsed)
if powerEntryMode == .power {
synchronizeCurrentWithPower()
} else {
updatePowerFromCurrent()
}
}
Button(alertCancelTitle, role: .cancel) {
editingField = nil
outputVoltageInput = ""
}
Button(alertSaveTitle) {
if let parsed = parseInput(outputVoltageInput) {
configuration.outputVoltage = roundToTenth(parsed)
if powerEntryMode == .power {
synchronizeCurrentWithPower()
} else {
updatePowerFromCurrent()
}
}
editingField = nil
outputVoltageInput = ""
}
} message: {
Text(alertMessageVoltage)
}
.alert(
currentAlertTitle,
isPresented: Binding(
get: { editingField == .current },
set: { isPresented in
if !isPresented {
editingField = nil
currentInput = ""
}
}
)
) {
TextField(
currentLabel,
text: $currentInput
)
.keyboardType(.decimalPad)
.onAppear {
if currentInput.isEmpty {
currentInput = formattedEditValue(configuration.maxCurrentAmps)
}
}
.onChange(of: currentInput) { _, newValue in
guard editingField == .current, let parsed = parseInput(newValue) else { return }
configuration.maxCurrentAmps = roundToTenth(parsed)
configuration.maxPowerWatts = 0
updatePowerFromCurrent()
}
Button(alertCancelTitle, role: .cancel) {
editingField = nil
currentInput = ""
}
Button(alertSaveTitle) {
if let parsed = parseInput(currentInput) {
configuration.maxCurrentAmps = roundToTenth(parsed)
configuration.maxPowerWatts = 0
updatePowerFromCurrent()
}
editingField = nil
currentInput = ""
}
} message: {
Text(alertMessageCurrent)
}
.alert(
powerAlertTitle,
isPresented: Binding(
get: { editingField == .power },
set: { isPresented in
if !isPresented {
editingField = nil
powerInput = ""
}
}
)
) {
TextField(
powerAlertPlaceholder,
text: $powerInput
)
.keyboardType(.decimalPad)
.onAppear {
if powerInput.isEmpty {
powerInput = formattedPowerEditValue(displayedPowerValue)
}
}
.onChange(of: powerInput) { _, newValue in
guard editingField == .power, let parsed = parseInput(newValue) else { return }
let normalized = normalizedPower(for: parsed)
configuration.maxPowerWatts = normalized
lastManualPowerWatts = normalized
synchronizeCurrentWithPower()
}
Button(alertCancelTitle, role: .cancel) {
editingField = nil
powerInput = ""
}
Button(alertSaveTitle) {
if let parsed = parseInput(powerInput) {
let normalized = normalizedPower(for: parsed)
configuration.maxPowerWatts = normalized
lastManualPowerWatts = normalized
powerEntryMode = .power
synchronizeCurrentWithPower()
}
editingField = nil
powerInput = ""
}
} message: {
Text(alertMessagePower)
}
}
private var navigationTitleView: some View {
Button {
showingAppearanceEditor = true
} label: {
HStack(spacing: 8) {
LoadIconView(
remoteIconURLString: nil,
fallbackSystemName: configuration.iconName.isEmpty ? "bolt.fill" : configuration.iconName,
fallbackColor: iconColor,
size: 26
)
Text(displayName)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(appearanceAccessibilityLabel)
}
private var electricalSection: some View {
Section {
SliderSection(
title: inputVoltageLabel,
value: Binding(
get: { configuration.inputVoltage },
set: { newValue in
if editingField == .inputVoltage {
configuration.inputVoltage = roundToTenth(newValue)
} else {
configuration.inputVoltage = normalizedInputVoltage(for: newValue)
}
}
),
range: inputVoltageSliderRange,
unit: "V",
tapAction: beginInputVoltageEditing,
snapValues: editingField == .inputVoltage ? nil : inputVoltageSnapValues
)
.listRowSeparator(.hidden)
SliderSection(
title: outputVoltageLabel,
value: Binding(
get: { configuration.outputVoltage },
set: { newValue in
if editingField == .outputVoltage {
configuration.outputVoltage = roundToTenth(newValue)
} else {
configuration.outputVoltage = normalizedOutputVoltage(for: newValue)
}
if powerEntryMode == .power {
synchronizeCurrentWithPower()
} else {
updatePowerFromCurrent()
}
}
),
range: outputVoltageSliderRange,
unit: "V",
tapAction: beginOutputVoltageEditing,
snapValues: editingField == .outputVoltage ? nil : outputVoltageSnapValues
)
.listRowSeparator(.hidden)
}
.listRowBackground(Color(.systemBackground))
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
}
private var chargingSection: some View {
Section {
if powerEntryMode == .power {
VStack(alignment: .leading, spacing: 12) {
SliderSection(
title: powerLabel,
value: Binding(
get: { displayedPowerValue },
set: { newValue in
let normalized = editingField == .power ? roundToNearestFive(newValue) : normalizedPower(for: newValue)
configuration.maxPowerWatts = normalized
lastManualPowerWatts = normalized
synchronizeCurrentWithPower()
}
),
range: powerSliderRange,
unit: "W",
buttonText: ampButtonTitle,
buttonAction: switchToCurrentMode,
tapAction: beginPowerEditing,
snapValues: editingField == .power ? nil : powerSnapValues
)
}
.listRowSeparator(.hidden)
} else {
SliderSection(
title: currentLabel,
value: Binding(
get: { configuration.maxCurrentAmps },
set: { newValue in
if editingField == .current {
configuration.maxCurrentAmps = roundToTenth(newValue)
} else {
configuration.maxCurrentAmps = normalizedCurrent(for: newValue)
}
configuration.maxPowerWatts = 0
updatePowerFromCurrent()
}
),
range: currentSliderRange,
unit: "A",
buttonText: wattButtonTitle,
buttonAction: switchToPowerMode,
tapAction: beginCurrentEditing,
snapValues: editingField == .current ? nil : currentSnapValues
)
.listRowSeparator(.hidden)
}
}
.listRowBackground(Color(.systemBackground))
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
}
private var headerInfoBar: some View {
VStack(spacing: 0) {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
overviewChip(
icon: "powerplug",
title: inputBadgeLabel.uppercased(),
value: formattedVoltage(configuration.inputVoltage),
tint: .indigo
)
overviewChip(
icon: "bolt.fill",
title: outputBadgeLabel.uppercased(),
value: formattedVoltage(configuration.outputVoltage),
tint: .green
)
overviewChip(
icon: "gauge.medium",
title: currentBadgeLabel.uppercased(),
value: formattedCurrent(configuration.maxCurrentAmps),
tint: .orange
)
overviewChip(
icon: "bolt.circle",
title: powerBadgeLabel.uppercased(),
value: formattedPower(configuration.effectivePowerWatts),
tint: .pink
)
}
.padding(.horizontal, 18)
}
.scrollClipDisabled(true)
}
.padding(.vertical, 14)
.background(Color(.systemGroupedBackground))
}
private func overviewChip(icon: String, title: String, value: String, tint: Color) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
Text(title)
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(tint.opacity(0.12))
)
}
private var displayedPowerValue: Double {
if configuration.maxPowerWatts > 0 {
return configuration.maxPowerWatts
}
if lastManualPowerWatts > 0 {
return lastManualPowerWatts
}
return max(0, configuration.outputVoltage * configuration.maxCurrentAmps)
}
private func switchToPowerMode() {
if configuration.maxPowerWatts <= 0 {
let candidate = lastManualPowerWatts > 0 ? lastManualPowerWatts : configuration.outputVoltage * configuration.maxCurrentAmps
let normalized = normalizedPower(for: candidate)
configuration.maxPowerWatts = normalized
lastManualPowerWatts = normalized
} else {
lastManualPowerWatts = configuration.maxPowerWatts
}
powerEntryMode = .power
synchronizeCurrentWithPower()
}
private func switchToCurrentMode() {
if configuration.maxPowerWatts > 0 {
lastManualPowerWatts = configuration.maxPowerWatts
}
configuration.maxPowerWatts = 0
powerEntryMode = .current
updatePowerFromCurrent()
}
private func synchronizeCurrentWithPower() {
guard powerEntryMode == .power else { return }
guard configuration.maxPowerWatts > 0 else {
configuration.maxCurrentAmps = 0
return
}
let voltage = max(configuration.outputVoltage, 0.1)
let derivedCurrent = configuration.maxPowerWatts / voltage
configuration.maxCurrentAmps = roundToTenth(derivedCurrent)
}
private func updatePowerFromCurrent() {
let derivedPower = configuration.outputVoltage * configuration.maxCurrentAmps
lastManualPowerWatts = normalizedPower(for: derivedPower)
}
private func normalizedInputVoltage(for value: Double) -> Double {
let rounded = roundToTenth(value)
if let snapped = nearestValue(to: rounded, in: inputVoltageSnapValues, tolerance: inputVoltageSnapTolerance) {
return snapped
}
return rounded
}
private func normalizedOutputVoltage(for value: Double) -> Double {
let rounded = roundToTenth(value)
if let snapped = nearestValue(to: rounded, in: outputVoltageSnapValues, tolerance: outputVoltageSnapTolerance) {
return snapped
}
return rounded
}
private func normalizedCurrent(for value: Double) -> Double {
let rounded = roundToTenth(value)
if let snapped = nearestValue(to: rounded, in: currentSnapValues, tolerance: currentSnapTolerance) {
return snapped
}
return rounded
}
private func normalizedPower(for value: Double) -> Double {
let rounded = roundToNearestFive(value)
if let snapped = nearestValue(to: rounded, in: powerSnapValues, tolerance: powerSnapTolerance) {
return snapped
}
return rounded
}
private func roundToTenth(_ value: Double) -> Double {
max(0, (value * 10).rounded() / 10)
}
private func roundToNearestFive(_ value: Double) -> Double {
max(0, (value / 5).rounded() * 5)
}
private func formattedEditValue(_ value: Double) -> String {
Self.decimalFormatter.string(from: NSNumber(value: roundToTenth(value))) ?? String(format: "%.1f", value)
}
private func parseInput(_ text: String) -> Double? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let number = Self.decimalFormatter.number(from: trimmed)?.doubleValue {
return number
}
let decimalSeparator = Locale.current.decimalSeparator ?? "."
let altSeparator = decimalSeparator == "." ? "," : "."
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
return Self.decimalFormatter.number(from: normalized)?.doubleValue
}
private func beginInputVoltageEditing() {
inputVoltageInput = formattedEditValue(configuration.inputVoltage)
editingField = .inputVoltage
}
private func beginOutputVoltageEditing() {
outputVoltageInput = formattedEditValue(configuration.outputVoltage)
editingField = .outputVoltage
}
private func beginCurrentEditing() {
currentInput = formattedEditValue(configuration.maxCurrentAmps)
editingField = .current
}
private func beginPowerEditing() {
powerInput = formattedPowerEditValue(displayedPowerValue)
editingField = .power
}
private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? {
guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
return abs(closest - value) <= tolerance ? closest : nil
}
private func formattedVoltage(_ value: Double) -> String {
let numberString = Self.decimalFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) V"
}
private func formattedCurrent(_ value: Double) -> String {
let numberString = Self.decimalFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
return "\(numberString) A"
}
private func formattedPower(_ value: Double) -> String {
guard value > 0 else { return "— W" }
let numberString = Self.powerFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
return "\(numberString) W"
}
private func formattedPowerEditValue(_ value: Double) -> String {
guard value > 0 else { return "" }
return Self.powerFormatter.string(from: NSNumber(value: value)) ?? String(format: "%.0f", value)
}
private static let decimalFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 1
return formatter
}()
private static let powerFormatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = .current
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = 0
formatter.maximumFractionDigits = 0
return formatter
}()
}
#Preview {
let previewSystem = ElectricalSystem(name: "Camper")
return NavigationStack {
ChargerEditorView(
configuration: ChargerConfiguration(
name: "Workshop Charger",
inputVoltage: 230,
outputVoltage: 14.4,
maxCurrentAmps: 40,
maxPowerWatts: 580,
iconName: "bolt.fill",
colorName: "orange",
system: previewSystem
),
onSave: { _ in }
)
}
}

View File

@@ -0,0 +1,397 @@
import SwiftUI
struct ChargersView: View {
@Binding var editMode: EditMode
let system: ElectricalSystem
let chargers: [SavedCharger]
let onAdd: () -> Void
let onEdit: (SavedCharger) -> Void
let onDelete: (IndexSet) -> Void
private struct SummaryMetric: Identifiable {
let id: String
let icon: String
let label: String
let value: String
let tint: Color
}
private var summaryTitle: String {
String(
localized: "chargers.summary.title",
bundle: .main,
comment: "Title for the chargers summary section"
)
}
private var summaryCountLabel: String {
String(
localized: "chargers.summary.metric.count",
bundle: .main,
comment: "Label for number of chargers metric"
)
}
private var summaryOutputLabel: String {
String(
localized: "chargers.summary.metric.output",
bundle: .main,
comment: "Label for output voltage metric"
)
}
private var summaryCurrentLabel: String {
String(
localized: "chargers.summary.metric.current",
bundle: .main,
comment: "Label for combined current metric"
)
}
private var summaryPowerLabel: String {
String(
localized: "chargers.summary.metric.power",
bundle: .main,
comment: "Label for combined power metric"
)
}
private var badgeInputLabel: String {
String(
localized: "chargers.badge.input",
bundle: .main,
comment: "Label for input voltage badge"
)
}
private var badgeOutputLabel: String {
String(
localized: "chargers.badge.output",
bundle: .main,
comment: "Label for output voltage badge"
)
}
private var badgeCurrentLabel: String {
String(
localized: "chargers.badge.current",
bundle: .main,
comment: "Label for charging current badge"
)
}
private var badgePowerLabel: String {
String(
localized: "chargers.badge.power",
bundle: .main,
comment: "Label for charging power badge"
)
}
init(
system: ElectricalSystem,
chargers: [SavedCharger],
editMode: Binding<EditMode> = .constant(.inactive),
onAdd: @escaping () -> Void = {},
onEdit: @escaping (SavedCharger) -> Void = { _ in },
onDelete: @escaping (IndexSet) -> Void = { _ in }
) {
self.system = system
self.chargers = chargers
self.onAdd = onAdd
self.onEdit = onEdit
self.onDelete = onDelete
_editMode = editMode
}
var body: some View {
VStack(spacing: 0) {
if chargers.isEmpty {
emptyState
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
chargersListWithHeader
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
}
private var chargerStatsHeader: some View {
StatsHeaderContainer {
chargerSummaryContent
}
}
private var chargerSummaryContent: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(summaryTitle)
.font(.headline.weight(.semibold))
Spacer()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(summaryMetrics) { metric in
ComponentSummaryMetricView(
icon: metric.icon,
label: metric.label,
value: metric.value,
tint: metric.tint
)
}
}
.padding(.horizontal, 2)
}
.scrollClipDisabled(false)
}
}
}
@ViewBuilder
private var chargersListWithHeader: some View {
if #available(iOS 26.0, *) {
baseChargersList
.scrollEdgeEffectStyle(.soft, for: .top)
.safeAreaInset(edge: .top, spacing: 0) {
chargerStatsHeader
}
} else {
baseChargersList
.safeAreaInset(edge: .top, spacing: 0) {
chargerStatsHeader
}
}
}
private var baseChargersList: some View {
List {
ForEach(chargers) { charger in
Button {
onEdit(charger)
} label: {
chargerRow(for: charger)
}
.buttonStyle(.plain)
.disabled(editMode == .active)
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
.onDelete(perform: onDelete)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, $editMode)
.accessibilityIdentifier("chargers-list")
}
private var summaryMetrics: [SummaryMetric] {
guard !chargers.isEmpty else { return [] }
var metrics: [SummaryMetric] = [
SummaryMetric(
id: "count",
icon: "bolt.fill",
label: summaryCountLabel,
value: "\(chargers.count)",
tint: .blue
)
]
if let output = representativeOutputVoltage {
metrics.append(
SummaryMetric(
id: "output",
icon: "battery.100.bolt",
label: summaryOutputLabel,
value: formattedVoltage(output),
tint: .green
)
)
}
if totalCurrent > 0 {
metrics.append(
SummaryMetric(
id: "current",
icon: "gauge",
label: summaryCurrentLabel,
value: formattedCurrent(totalCurrent),
tint: .orange
)
)
}
if totalPower > 0 {
metrics.append(
SummaryMetric(
id: "power",
icon: "bolt.badge.a",
label: summaryPowerLabel,
value: formattedPower(totalPower),
tint: .pink
)
)
}
return metrics
}
private var emptyState: some View {
OnboardingInfoView(
configuration: .charger(),
onPrimaryAction: onAdd
)
.padding(.horizontal, 0)
}
private func chargerRow(for charger: SavedCharger) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
LoadIconView(
remoteIconURLString: charger.remoteIconURLString,
fallbackSystemName: charger.iconName,
fallbackColor: Color.componentColor(named: charger.colorName),
size: 48
)
VStack(alignment: .leading, spacing: 4) {
Text(charger.name)
.font(.body.weight(.medium))
.lineLimit(1)
.truncationMode(.tail)
Text(chargerSummary(for: charger))
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule(style: .continuous)
.fill(Color(.tertiarySystemBackground))
)
}
Spacer()
if editMode == .inactive {
Image(systemName: "chevron.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
}
}
metricsSection(for: charger)
}
.padding(.vertical, 16)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(.systemBackground))
)
}
@ViewBuilder
private func metricsSection(for charger: SavedCharger) -> some View {
let badges: [(String, String, Color)] = [
(badgeInputLabel, formattedVoltage(charger.inputVoltage), .indigo),
(badgeOutputLabel, formattedVoltage(charger.outputVoltage), .green),
(badgeCurrentLabel, formattedCurrent(charger.maxCurrentAmps), .orange),
(badgePowerLabel, formattedPower(charger.effectivePowerWatts), .pink)
]
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(badges, id: \.0) { label, value, tint in
ComponentMetricBadgeView(label: label, value: value, tint: tint)
}
}
.padding(.trailing, 16)
}
.scrollClipDisabled(true)
}
private func chargerSummary(for charger: SavedCharger) -> String {
let inputText = formattedVoltage(charger.inputVoltage)
let outputText = formattedVoltage(charger.outputVoltage)
let currentText = formattedCurrent(charger.maxCurrentAmps)
return [inputText, outputText, currentText].joined(separator: "")
}
private var totalCurrent: Double {
chargers.reduce(0) { result, charger in
result + max(0, charger.maxCurrentAmps)
}
}
private var totalPower: Double {
chargers.reduce(0) { result, charger in
result + max(0, charger.effectivePowerWatts)
}
}
private var representativeOutputVoltage: Double? {
let outputs = chargers.map { $0.outputVoltage }.filter { $0 > 0 }
guard !outputs.isEmpty else { return nil }
let total = outputs.reduce(0, +)
return total / Double(outputs.count)
}
private func formattedVoltage(_ value: Double) -> String {
guard value > 0 else { return "" }
return String(format: "%.1fV", value)
}
private func formattedCurrent(_ value: Double) -> String {
guard value > 0 else { return "" }
return String(format: "%.1fA", value)
}
private func formattedPower(_ value: Double) -> String {
guard value > 0 else { return "" }
return String(format: "%.0fW", value)
}
}
private enum ChargersViewPreviewData {
static let system = ElectricalSystem(name: "Preview System", iconName: "bolt", colorName: "teal")
static let chargers: [SavedCharger] = {
let shore = SavedCharger(
name: "Shore Power",
inputVoltage: 230,
outputVoltage: 14.4,
maxCurrentAmps: 40,
maxPowerWatts: 580,
iconName: "powerplug",
colorName: "orange",
system: system
)
shore.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
let dcDc = SavedCharger(
name: "DC-DC Charger",
inputVoltage: 12.8,
outputVoltage: 14.2,
maxCurrentAmps: 30,
maxPowerWatts: 0,
iconName: "bolt.badge.clock",
colorName: "blue",
system: system
)
dcDc.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
return [shore, dcDc]
}()
}
#Preview {
ChargersView(
system: ChargersViewPreviewData.system,
chargers: ChargersViewPreviewData.chargers,
editMode: .constant(.inactive)
)
}

View File

@@ -0,0 +1,62 @@
import Foundation
import SwiftData
@Model
final class SavedCharger {
@Attribute(.unique) var id: UUID
var name: String
var inputVoltage: Double
var outputVoltage: Double
var maxCurrentAmps: Double
var maxPowerWatts: Double
var iconName: String
var colorName: String
var system: ElectricalSystem?
var timestamp: Date
var remoteIconURLString: String?
var affiliateURLString: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = []
var identifier: String
init(
id: UUID = UUID(),
name: String,
inputVoltage: Double = 230.0,
outputVoltage: Double = 14.2,
maxCurrentAmps: Double = 30.0,
maxPowerWatts: Double = 0.0,
iconName: String = "bolt.fill",
colorName: String = "orange",
system: ElectricalSystem? = nil,
timestamp: Date = Date(),
remoteIconURLString: String? = nil,
affiliateURLString: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString
) {
self.id = id
self.name = name
self.inputVoltage = inputVoltage
self.outputVoltage = outputVoltage
self.maxCurrentAmps = maxCurrentAmps
self.maxPowerWatts = maxPowerWatts
self.iconName = iconName
self.colorName = colorName
self.system = system
self.timestamp = timestamp
self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier
}
var effectivePowerWatts: Double {
if maxPowerWatts > 0 {
return maxPowerWatts
}
return outputVoltage * maxCurrentAmps
}
}

View File

@@ -1,121 +0,0 @@
import SwiftUI
struct ComponentsOnboardingView: View {
@State private var carouselStep = 0
let onCreate: () -> Void
let onBrowse: () -> Void
private let imageNames = [
"coffee-onboarding",
"router-onboarding",
"charger-onboarding"
]
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
private let animationDuration = 0.8
private var loopingImages: [String] {
guard let first = imageNames.first else { return [] }
return imageNames + [first]
}
var body: some View {
VStack {
Spacer(minLength: 32)
OnboardingCarouselView(images: loopingImages, step: carouselStep)
.frame(minHeight: 80, maxHeight: 240)
.padding(.horizontal, 0)
VStack(spacing: 12) {
Text("Add your first component")
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text("Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.")
.font(.body)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
.frame(minHeight: 72)
.padding(.horizontal, 12)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 12) {
Button(action: createComponent) {
HStack(spacing: 8) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 16))
Text("Create Component")
.font(.headline.weight(.semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(Color.blue)
.cornerRadius(12)
}
.accessibilityIdentifier("create-component-button")
.buttonStyle(.plain)
Button(action: onBrowse) {
HStack(spacing: 8) {
Image(systemName: "books.vertical")
.font(.system(size: 16))
Text("Browse Library")
.font(.headline.weight(.semibold))
}
.foregroundColor(.blue)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.blue.opacity(0.12))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
)
}
.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: {})
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let id: String
let name: String
let translations: [String: String]
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
@@ -39,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,14 +559,17 @@ struct ComponentLibraryView: View {
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
List(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
List {
ForEach(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
poweredByVoltplanRow
}
.listStyle(.insetGrouped)
}
@@ -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

View File

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

View File

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

View File

@@ -75,3 +75,77 @@ struct LoadIconView: View {
}
}
}
struct ComponentSummaryMetricView: View {
let icon: String
let label: String
let value: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(tint)
Text(value)
.font(.body.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Text(label.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
struct ComponentMetricBadgeView: View {
let label: String
let value: String
let tint: Color
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(label.uppercased())
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.lineLimit(1)
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(tint.opacity(0.12))
)
}
}
extension Color {
static func componentColor(named colorName: String) -> Color {
switch colorName {
case "blue": return .blue
case "green": return .green
case "orange": return .orange
case "red": return .red
case "purple": return .purple
case "yellow": return .yellow
case "pink": return .pink
case "teal": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "cyan": return .cyan
case "brown": return .brown
case "gray": return .gray
default: return .blue
}
}
}

1003
Cable/Loads/LoadsView.swift Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,165 @@
import SwiftUI
struct OnboardingInfoView: View {
struct Configuration {
let title: LocalizedStringKey
let subtitle: LocalizedStringKey
let primaryActionTitle: LocalizedStringKey
let primaryActionIcon: String
let secondaryActionTitle: LocalizedStringKey?
let secondaryActionIcon: String?
let imageNames: [String]
}
@State private var carouselStep = 0
private let configuration: Configuration
private let onPrimaryAction: () -> Void
private let onSecondaryAction: () -> Void
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
private let animationDuration = 0.8
private var loopingImages: [String] {
guard let first = configuration.imageNames.first else { return [] }
return configuration.imageNames + [first]
}
init(configuration: Configuration, onPrimaryAction: @escaping () -> Void, onSecondaryAction: @escaping () -> Void = {}) {
self.configuration = configuration
self.onPrimaryAction = onPrimaryAction
self.onSecondaryAction = onSecondaryAction
}
var body: some View {
VStack(spacing: 24) {
Spacer(minLength: 32)
if !loopingImages.isEmpty {
OnboardingCarouselView(images: loopingImages, step: carouselStep)
.frame(minHeight: 80, maxHeight: 220)
.padding(.horizontal, 0)
}
VStack(spacing: 12) {
Text(configuration.title)
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text(configuration.subtitle)
.font(.body)
.foregroundStyle(Color.secondary)
.multilineTextAlignment(.center)
.frame(minHeight: 72)
.padding(.horizontal, 12)
}
.padding(.horizontal, 24)
Spacer()
VStack(spacing: 12) {
Spacer()
Button(action: onPrimaryAction) {
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("create-component-button")
.buttonStyle(.borderedProminent)
.controlSize(.large)
if let secondaryTitle = configuration.secondaryActionTitle,
let secondaryIcon = configuration.secondaryActionIcon {
Button(action: onSecondaryAction) {
Label(secondaryTitle, systemImage: secondaryIcon)
.frame(maxWidth: .infinity)
}
.accessibilityIdentifier("select-component-button")
.buttonStyle(.bordered)
.tint(.accentColor)
.controlSize(.large)
}
}
.padding(.horizontal, 24)
.frame(minHeight: 140)
}
.padding(.vertical, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemGroupedBackground))
.onAppear(perform: resetState)
.onReceive(timer) { _ in advanceCarousel() }
}
private func resetState() {
carouselStep = 0
}
private func advanceCarousel() {
guard configuration.imageNames.count > 1 else { return }
let next = carouselStep + 1
withAnimation(.easeInOut(duration: animationDuration)) {
carouselStep = next
}
if next == configuration.imageNames.count {
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
withAnimation(.none) {
carouselStep = 0
}
}
}
}
}
#Preview {
OnboardingInfoView(
configuration: .loads(),
onPrimaryAction: {},
onSecondaryAction: {}
)
}
extension OnboardingInfoView.Configuration {
static func loads() -> Self {
Self(
title: LocalizedStringKey("loads.onboarding.title"),
subtitle: LocalizedStringKey("loads.onboarding.subtitle"),
primaryActionTitle: LocalizedStringKey("loads.overview.empty.create"),
primaryActionIcon: "plus",
secondaryActionTitle: LocalizedStringKey("loads.overview.empty.library"),
secondaryActionIcon: "books.vertical",
imageNames: [
"coffee-onboarding",
"router-onboarding",
"charger-onboarding"
]
)
}
static func battery() -> Self {
Self(
title: LocalizedStringKey("battery.onboarding.title"),
subtitle: LocalizedStringKey("battery.onboarding.subtitle"),
primaryActionTitle: LocalizedStringKey("battery.overview.empty.create"),
primaryActionIcon: "plus",
secondaryActionTitle: nil,
secondaryActionIcon: nil,
imageNames: [
"battery-onboarding"
]
)
}
static func charger() -> Self {
Self(
title: LocalizedStringKey("chargers.onboarding.title"),
subtitle: LocalizedStringKey("chargers.onboarding.subtitle"),
primaryActionTitle: LocalizedStringKey("chargers.onboarding.primary"),
primaryActionIcon: "plus",
secondaryActionTitle: nil,
secondaryActionIcon: nil,
imageNames: [
"charger-onboarding"
]
)
}
}

File diff suppressed because it is too large Load Diff

View File

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

82
Cable/SavedBattery.swift Normal file
View File

@@ -0,0 +1,82 @@
import Foundation
import SwiftData
@Model
class SavedBattery {
@Attribute(.unique) var id: UUID
var name: String
var nominalVoltage: Double
var capacityAmpHours: Double
var usableCapacityOverrideFraction: Double?
var chargeVoltage: Double?
var cutOffVoltage: Double?
var minimumTemperatureCelsius: Double?
var maximumTemperatureCelsius: Double?
private var chemistryRawValue: String
var iconName: String = "battery.100"
var colorName: String = "blue"
var system: ElectricalSystem?
var bomCompletedItemIDs: [String] = []
var timestamp: Date
init(
id: UUID = UUID(),
name: String,
nominalVoltage: Double = 12.8,
capacityAmpHours: Double = 100,
chemistry: BatteryConfiguration.Chemistry = .lithiumIronPhosphate,
usableCapacityOverrideFraction: Double? = nil,
chargeVoltage: Double? = nil,
cutOffVoltage: Double? = nil,
minimumTemperatureCelsius: Double? = nil,
maximumTemperatureCelsius: Double? = nil,
iconName: String = "battery.100",
colorName: String = "blue",
system: ElectricalSystem? = nil,
bomCompletedItemIDs: [String] = [],
timestamp: Date = Date()
) {
self.id = id
self.name = name
self.nominalVoltage = nominalVoltage
self.capacityAmpHours = capacityAmpHours
self.usableCapacityOverrideFraction = usableCapacityOverrideFraction
self.chargeVoltage = chargeVoltage
self.cutOffVoltage = cutOffVoltage
self.minimumTemperatureCelsius = minimumTemperatureCelsius
self.maximumTemperatureCelsius = maximumTemperatureCelsius
self.chemistryRawValue = chemistry.rawValue
self.iconName = iconName
self.colorName = colorName
self.system = system
self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp
}
var chemistry: BatteryConfiguration.Chemistry {
get {
BatteryConfiguration.Chemistry(rawValue: chemistryRawValue) ?? .lithiumIronPhosphate
}
set {
chemistryRawValue = newValue.rawValue
}
}
var energyWattHours: Double {
nominalVoltage * capacityAmpHours
}
var usableCapacityAmpHours: Double {
let fraction: Double
if let override = usableCapacityOverrideFraction {
fraction = max(0, min(1, override))
} else {
fraction = chemistry.usableCapacityFraction
}
return capacityAmpHours * fraction
}
var usableEnergyWattHours: Double {
usableCapacityAmpHours * nominalVoltage
}
}

212
Cable/SettingsView.swift Normal file
View File

@@ -0,0 +1,212 @@
//
// SettingsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@EnvironmentObject private var storeKitManager: StoreKitManager
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var showingProPaywall = false
var body: some View {
NavigationStack {
Form {
Section("Units") {
Picker("Unit System", selection: $unitSettings.unitSystem) {
ForEach(UnitSystem.allCases, id: \.self) { system in
Text(system.displayName).tag(system)
}
}
.pickerStyle(.segmented)
}
Section("Cable PRO") {
proSectionContent
}
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 18))
Text("Safety Disclaimer")
.font(.headline)
.fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: 8) {
Text("This application provides electrical calculations for educational and estimation purposes only.")
.font(.body)
Text("Important:")
.font(.subheadline)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) {
Text("• Always consult qualified electricians for actual installations")
Text("• Follow all local electrical codes and regulations")
Text("• Electrical work should only be performed by licensed professionals")
Text("• These calculations may not account for all environmental factors")
Text("• The app developers assume no liability for electrical installations")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
}
}
}
.sheet(isPresented: $showingProPaywall) {
CableProPaywallView(isPresented: $showingProPaywall)
}
.onChange(of: showingProPaywall) { isPresented in
if !isPresented {
Task { await storeKitManager.refreshEntitlements() }
}
}
.onAppear {
Task { await storeKitManager.refreshEntitlements() }
}
}
@ViewBuilder
private var proSectionContent: some View {
if storeKitManager.isRefreshing && storeKitManager.status == nil {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else if let status = storeKitManager.status {
VStack(alignment: .leading, spacing: 8) {
Label(status.displayName, systemImage: "checkmark.seal.fill")
.font(.headline)
if let renewalDate = status.renewalDate {
Text(renewalText(for: renewalDate))
.font(.footnote)
.foregroundStyle(.secondary)
}
if let trialText = trialMessage(for: status) {
Text(trialText)
.font(.footnote)
.foregroundStyle(.secondary)
}
if status.isInGracePeriod {
Text(localizedString("settings.pro.grace_period", defaultValue: "We're retrying your last payment; access remains during the grace period."))
.font(.footnote)
.foregroundStyle(.secondary)
}
if let isAutoRenewEnabled = status.isAutoRenewEnabled, !isAutoRenewEnabled {
Text(localizedString("settings.pro.autorenew.off", defaultValue: "Auto-renew is off—consider renewing to keep Cable PRO."))
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 4)
Button {
openManageSubscriptions()
} label: {
Text(localizedString("settings.pro.manage.button", defaultValue: "Manage Subscription"))
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(6)
}
.buttonStyle(.borderedProminent)
}
.padding(.vertical, 4)
} else {
VStack(alignment: .leading, spacing: 10) {
Text(localizedString("settings.pro.cta.description", defaultValue: "Cable PRO keeps advanced calculations and early tools available."))
.font(.body)
.foregroundStyle(.secondary)
Button {
showingProPaywall = true
} label: {
Text(localizedString("settings.pro.cta.button", defaultValue: "Get Cable PRO"))
.font(.callout.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(6)
}
.buttonStyle(.borderedProminent)
}
}
}
private func renewalText(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
formatter.locale = Locale.autoupdatingCurrent
let dateString = formatter.string(from: date)
let template = localizedString("settings.pro.renewal.date", defaultValue: "Renews on %@.")
return String(format: template, dateString)
}
private func trialMessage(for status: StoreKitManager.SubscriptionStatus) -> String? {
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
if days > 0 {
let dayText = localizedDayCount(days)
let template = localizedString("settings.pro.trial.remaining", defaultValue: "%@ remaining in free trial.")
return String(format: template, dayText)
} else {
return localizedString("settings.pro.trial.today", defaultValue: "Free trial renews today.")
}
}
private func localizedDayCount(_ days: Int) -> String {
let number = localizedNumber(days)
let key = days == 1 ? "settings.pro.day.one" : "settings.pro.day.other"
let template = localizedString(key, defaultValue: days == 1 ? "%@ day" : "%@ days")
return String(format: template, number)
}
private func openManageSubscriptions() {
guard let url = URL(string: localizedString("settings.pro.manage.url", defaultValue: "https://apps.apple.com/account/subscriptions")) else { return }
openURL(url)
}
private func localizedNumber(_ value: Int) -> String {
let formatter = NumberFormatter()
formatter.locale = Locale.autoupdatingCurrent
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: value)) ?? String(value)
}
private func localizedString(_ key: String, defaultValue: String) -> String {
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
}
}
#Preview("Settings (Default)") {
let settings = UnitSystemSettings()
let manager = StoreKitManager(unitSettings: settings)
return SettingsView()
.environmentObject(settings)
.environmentObject(manager)
}

View File

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

View File

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

221
Cable/StoreKitManager.swift Normal file
View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,598 @@
//
// SystemsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@State private var systemNavigationTarget: SystemNavigationTarget?
@State private var showingComponentLibrary = false
@State private var showingSettings = false
@State private var hasPerformedInitialAutoNavigation = false
private let systemColorOptions = [
"blue", "green", "orange", "red", "purple", "yellow",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
]
private let defaultSystemIconName = "building.2"
private var systemIconMappings: [(keywords: [String], icon: String)] {
[
(keywords(for: "system.icon.keywords.rv", fallback: ["rv", "van", "camper", "motorhome", "coach"]), "bus"),
(keywords(for: "system.icon.keywords.truck", fallback: ["truck", "trailer", "rig"]), "truck.box"),
(keywords(for: "system.icon.keywords.boat", fallback: ["boat", "marine", "yacht", "sail"]), "sailboat"),
(keywords(for: "system.icon.keywords.plane", fallback: ["plane", "air", "flight"]), "airplane"),
(keywords(for: "system.icon.keywords.ferry", fallback: ["ferry", "ship"]), "ferry"),
(keywords(for: "system.icon.keywords.house", fallback: ["house", "home", "cabin", "cottage", "lodge"]), "house"),
(keywords(for: "system.icon.keywords.building", fallback: ["building", "office", "warehouse", "factory", "facility"]), "building"),
(keywords(for: "system.icon.keywords.tent", fallback: ["camp", "tent", "outdoor"]), "tent"),
(keywords(for: "system.icon.keywords.solar", fallback: ["solar", "sun"]), "sun.max"),
(keywords(for: "system.icon.keywords.battery", fallback: ["battery", "storage"]), "battery.100"),
(keywords(for: "system.icon.keywords.server", fallback: ["server", "data", "network", "rack"]), "server.rack"),
(keywords(for: "system.icon.keywords.computer", fallback: ["computer", "electronics", "lab", "tech"]), "cpu"),
(keywords(for: "system.icon.keywords.gear", fallback: ["gear", "mechanic", "machine", "workshop"]), "gear"),
(keywords(for: "system.icon.keywords.tool", fallback: ["tool", "maintenance", "repair", "shop"]), "wrench.adjustable"),
(keywords(for: "system.icon.keywords.hammer", fallback: ["hammer", "carpentry"]), "hammer"),
(keywords(for: "system.icon.keywords.light", fallback: ["light", "lighting", "lamp"]), "lightbulb"),
(keywords(for: "system.icon.keywords.bolt", fallback: ["bolt", "power", "electric"]), "bolt"),
(keywords(for: "system.icon.keywords.plug", fallback: ["plug"]), "powerplug"),
(keywords(for: "system.icon.keywords.engine", fallback: ["engine", "generator", "motor"]), "engine.combustion"),
(keywords(for: "system.icon.keywords.fuel", fallback: ["fuel", "diesel", "gas"]), "fuelpump"),
(keywords(for: "system.icon.keywords.water", fallback: ["water", "pump", "tank"]), "drop"),
(keywords(for: "system.icon.keywords.heat", fallback: ["heat", "heater", "furnace"]), "flame"),
(keywords(for: "system.icon.keywords.cold", fallback: ["cold", "freeze", "cool"]), "snowflake"),
(keywords(for: "system.icon.keywords.climate", fallback: ["climate", "hvac", "temperature"]), "thermometer")
]
}
private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID()
let system: ElectricalSystem
let presentSystemEditor: Bool
let loadToOpenOnAppear: SavedLoad?
static func == (lhs: SystemNavigationTarget, rhs: SystemNavigationTarget) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
var body: some View {
NavigationStack {
Group {
if systems.isEmpty {
systemsEmptyState
} else {
List {
ForEach(systems) { system in
Button {
handleSystemSelection(system)
} label: {
systemRow(for: system)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(system.name)
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
.accessibilityAddTraits(.isButton)
}
.onDelete(perform: deleteSystems)
}
.accessibilityIdentifier("systems-list")
}
}
.navigationTitle("Systems")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
openSettings()
} label: {
Image(systemName: "gearshape")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
AnalyticsTracker.log("System Create Navigation")
createNewSystem()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $systemNavigationTarget) { target in
LoadsView(
system: target.system,
presentSystemEditorOnAppear: target.presentSystemEditor,
loadToOpenOnAppear: target.loadToOpenOnAppear
)
}
}
.onAppear {
performInitialAutoNavigationIfNeeded()
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponentFromLibrary(item)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(unitSettings)
}
}
private var systemsEmptyState: some View {
SystemsOnboardingView { name in
createOnboardingSystem(named: name)
}
}
private func openSettings() {
AnalyticsTracker.log("Settings Opened")
showingSettings = true
}
private func handleSystemSelection(_ system: ElectricalSystem) {
AnalyticsTracker.log(
"System Opened",
properties: [
"name": system.name,
"source": "list"
]
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "list"
)
}
@ViewBuilder
private func systemRow(for system: ElectricalSystem) -> some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(Color.componentColor(named: system.colorName))
.frame(width: 44, height: 44)
Image(systemName: system.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote.weight(.semibold))
.foregroundColor(.secondary.opacity(0.6))
}
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func createNewSystem() {
let system = makeSystem()
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
"source": "toolbar"
]
)
navigateToSystem(
system,
presentSystemEditor: true,
loadToOpen: nil,
source: "created"
)
}
private func createNewSystem(named name: String) {
let system = makeSystem(preferredName: name)
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
"source": "named"
]
)
navigateToSystem(
system,
presentSystemEditor: true,
loadToOpen: nil,
source: "created-named"
)
}
private func createOnboardingSystem(named name: String) {
let system = makeSystem(
preferredName: name,
colorName: randomSystemColorName()
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
source: "onboarding"
)
}
private func navigateToSystem(
_ system: ElectricalSystem,
presentSystemEditor: Bool,
loadToOpen: SavedLoad?,
animated: Bool = true,
source: String = "programmatic"
) {
AnalyticsTracker.log(
"System Opened",
properties: [
"name": system.name,
"source": source,
"loads": loads(for: system).count
]
)
let target = SystemNavigationTarget(
system: system,
presentSystemEditor: presentSystemEditor,
loadToOpenOnAppear: loadToOpen
)
if animated {
systemNavigationTarget = target
} else {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
systemNavigationTarget = target
}
}
}
@discardableResult
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
let existingNames = Set(systems.map { $0.name })
let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let baseName = trimmedPreferred.isEmpty ? String(localized: "default.system.new", comment: "Default name for a newly created system") : trimmedPreferred
var systemName = baseName
var counter = 2
while existingNames.contains(systemName) {
systemName = "\(baseName) \(counter)"
counter += 1
}
let resolvedColorName = colorName ?? "blue"
let resolvedIconName = iconName ?? systemIconName(for: systemName)
let newSystem = ElectricalSystem(
name: systemName,
location: "",
iconName: resolvedIconName,
colorName: resolvedColorName
)
modelContext.insert(newSystem)
return newSystem
}
private func performInitialAutoNavigationIfNeeded() {
guard !hasPerformedInitialAutoNavigation else { return }
hasPerformedInitialAutoNavigation = true
guard systems.count == 1, let system = systems.first else { return }
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: nil,
animated: false,
source: "auto"
)
}
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
AnalyticsTracker.log(
"System Created",
properties: [
"name": system.name,
"source": "library"
]
)
let load = createLoad(from: item, in: system)
AnalyticsTracker.log(
"Library Load Added",
properties: [
"id": item.id,
"name": item.localizedName,
"system": system.name
]
)
navigateToSystem(
system,
presentSystemEditor: false,
loadToOpen: load,
animated: false,
source: "library"
)
}
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty
? String(localized: "default.load.library", comment: "Default name when importing a library load")
: localizedName
let loadName = uniqueLoadName(for: system, startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
let power: Double
if let watt = item.watt {
power = watt
} else if let derivedCurrent = item.current, voltage > 0 {
power = derivedCurrent * voltage
} else {
power = 0
}
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
return newLoad
}
private func uniqueLoadName(for system: ElectricalSystem, startingWith baseName: String) -> String {
let descriptor = FetchDescriptor<SavedLoad>()
let fetchedLoads = (try? modelContext.fetch(descriptor)) ?? []
let existingNames = Set(fetchedLoads.filter { $0.system == system }.map { $0.name })
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
private func deleteSystems(offsets: IndexSet) {
let systemsToDelete = offsets.map { systems[$0] }
withAnimation {
for system in systemsToDelete {
AnalyticsTracker.log(
"System Deleted",
properties: [
"name": system.name,
"loads": loads(for: system).count
]
)
deleteLoads(for: system)
modelContext.delete(system)
}
}
}
private func deleteLoads(for system: ElectricalSystem) {
let descriptor = FetchDescriptor<SavedLoad>()
if let loads = try? modelContext.fetch(descriptor) {
for load in loads where load.system == system {
AnalyticsTracker.log(
"Load Deleted",
properties: [
"name": load.name,
"system": system.name,
"source": "system-delete"
]
)
modelContext.delete(load)
}
}
}
private func loads(for system: ElectricalSystem) -> [SavedLoad] {
allLoads.filter { $0.system == system }
}
private func componentSummary(for system: ElectricalSystem) -> String {
let systemLoads = loads(for: system)
guard !systemLoads.isEmpty else {
return String(localized: "system.list.no.components", comment: "Message shown when a system has no components yet")
}
let count = systemLoads.count
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
let formattedPower: String
if totalPower >= 1000 {
formattedPower = String(format: "%.1fkW", totalPower / 1000)
} else {
formattedPower = String(format: "%.0fW", totalPower)
}
let format = NSLocalizedString(
"system.list.component.summary",
comment: "Summary showing number of components and the total power"
)
return String.localizedStringWithFormat(format, count, formattedPower)
}
private func randomSystemColorName() -> String {
systemColorOptions.randomElement() ?? "blue"
}
private func systemIconName(for name: String) -> String {
let normalized = name
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
for mapping in systemIconMappings {
if mapping.keywords.contains(where: { keyword in normalized.contains(keyword) }) {
return mapping.icon
}
}
return defaultSystemIconName
}
private func keywords(for localizationKey: String, fallback: [String]) -> [String] {
let fallbackValue = fallback.joined(separator: ",")
let localizedKeywords = NSLocalizedString(
localizationKey,
tableName: nil,
bundle: .main,
value: fallbackValue,
comment: ""
)
let separators = CharacterSet(charactersIn: ",;")
let components = localizedKeywords
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
var uniqueKeywords: [String] = []
for keyword in fallback.map({ $0.lowercased() }) + components {
if !uniqueKeywords.contains(keyword) {
uniqueKeywords.append(keyword)
}
}
return uniqueKeywords
}
}
#Preview("Sample Systems") {
// An in-memory SwiftData container for previews so we don't persist anything
let configuration = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try! ModelContainer(for: ElectricalSystem.self, SavedLoad.self, configurations: configuration)
// Seed sample data only once per preview session
if (try? ModelContext(container).fetch(FetchDescriptor<ElectricalSystem>()))?.isEmpty ?? true {
let context = ModelContext(container)
// Sample systems
let system1 = ElectricalSystem(name: "Camper Van", location: "Road Trip", iconName: "bus", colorName: "teal")
let system2 = ElectricalSystem(name: "Sailboat Aurora", location: "Marina 7", iconName: "sailboat", colorName: "blue")
context.insert(system1)
context.insert(system2)
// Sample loads for system 1
let load1 = SavedLoad(
name: "LED Cabin Light",
voltage: 12,
current: 0.5,
power: 6,
length: 5,
crossSection: 1.5,
iconName: "lightbulb",
colorName: "yellow",
isWattMode: false,
system: system1,
remoteIconURLString: nil,
affiliateURLString: nil,
affiliateCountryCode: nil
)
let load2 = SavedLoad(
name: "Water Pump",
voltage: 12,
current: 5,
power: 60,
length: 3,
crossSection: 2.5,
iconName: "drop",
colorName: "blue",
isWattMode: false,
system: system1,
remoteIconURLString: nil,
affiliateURLString: nil,
affiliateCountryCode: nil
)
// Sample loads for system 2
let load3 = SavedLoad(
name: "Navigation Lights",
voltage: 12,
current: 1.2,
power: 14.4,
length: 8,
crossSection: 1.5,
iconName: "lightbulb",
colorName: "green",
isWattMode: false,
system: system2,
remoteIconURLString: nil,
affiliateURLString: nil,
affiliateCountryCode: nil
)
context.insert(load1)
context.insert(load2)
context.insert(load3)
}
return SystemsView()
.modelContainer(container)
.environmentObject(UnitSystemSettings())
}

View File

@@ -0,0 +1,224 @@
//
// UITestSampleData.swift
// Cable
// Created by Stefan Lange-Hegermann on 06.10.25.
import Foundation
import SwiftData
enum UITestSampleData {
static let sampleArgument = "--uitest-sample-data"
static let resetArgument = "--uitest-reset-data"
static func handleLaunchArguments(container: ModelContainer) {
#if DEBUG
let arguments = ProcessInfo.processInfo.arguments
NSLog("UITestSampleData arguments: %@", arguments.joined(separator: ", "))
guard arguments.contains(sampleArgument) || arguments.contains(resetArgument) else { return }
let context = ModelContext(container)
do {
if arguments.contains(resetArgument) {
NSLog("UITestSampleData resetting data store")
try clearExistingData(in: context)
}
if arguments.contains(sampleArgument) {
NSLog("UITestSampleData seeding sample data")
if !arguments.contains(resetArgument) {
try clearExistingData(in: context)
}
try seedSampleData(in: context)
}
if context.hasChanges {
try context.save()
NSLog("UITestSampleData save completed")
}
} catch {
assertionFailure("Failed to prepare UI test data: \(error)")
}
#endif
}
}
#if DEBUG
extension UITestSampleData {
static func clearExistingData(in context: ModelContext) throws {
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
let loadDescriptor = FetchDescriptor<SavedLoad>()
let batteryDescriptor = FetchDescriptor<SavedBattery>()
let chargerDescriptor = FetchDescriptor<SavedCharger>()
let itemDescriptor = FetchDescriptor<Item>()
let systems = try context.fetch(systemDescriptor)
let loads = try context.fetch(loadDescriptor)
let batteries = try context.fetch(batteryDescriptor)
let chargers = try context.fetch(chargerDescriptor)
let items = try context.fetch(itemDescriptor)
systems.forEach { context.delete($0) }
loads.forEach { context.delete($0) }
batteries.forEach { context.delete($0) }
chargers.forEach { context.delete($0) }
items.forEach { context.delete($0) }
}
static func seedSampleData(in context: ModelContext) throws {
let adventureVan = ElectricalSystem(
name: String(localized: "sample.system.rv.name", comment: "Sample data name for the adventure van system"),
location: String(localized: "sample.system.rv.location", comment: "Sample data location for the adventure van system"),
iconName: "bus",
colorName: "orange"
)
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
let workshopBench = ElectricalSystem(
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
location: String(localized: "sample.system.workshop.location", comment: "Sample data location for the workshop system"),
iconName: "wrench.adjustable",
colorName: "teal"
)
workshopBench.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
context.insert(adventureVan)
context.insert(workshopBench)
let vanFridge = SavedLoad(
name: String(localized: "sample.load.fridge.name", comment: "Sample data load name for a compressor fridge"),
voltage: 12.0,
current: 4.2,
power: 50.0,
length: 6.0,
crossSection: 6.0,
iconName: "snowflake",
colorName: "blue",
isWattMode: true,
system: adventureVan,
identifier: "sample.load.fridge"
)
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
let vanLighting = SavedLoad(
name: String(localized: "sample.load.lighting.name", comment: "Sample data load name for LED strip lighting"),
voltage: 12.0,
current: 2.0,
power: 24.0,
length: 10.0,
crossSection: 2.5,
iconName: "lightbulb",
colorName: "yellow",
isWattMode: false,
system: adventureVan,
identifier: "sample.load.lighting"
)
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
let workshopCompressor = SavedLoad(
name: String(localized: "sample.load.compressor.name", comment: "Sample data load name for an air compressor"),
voltage: 120.0,
current: 8.0,
power: 960.0,
length: 15.0,
crossSection: 16.0,
iconName: "hammer",
colorName: "red",
isWattMode: true,
system: workshopBench,
identifier: "sample.load.compressor"
)
workshopCompressor.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
let workshopCharger = SavedLoad(
name: String(localized: "sample.load.charger.name", comment: "Sample data load name for a tool charger"),
voltage: 120.0,
current: 3.5,
power: 420.0,
length: 8.0,
crossSection: 10.0,
iconName: "battery.100",
colorName: "green",
isWattMode: false,
system: workshopBench,
identifier: "sample.load.charger"
)
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
let vanHouseBattery = SavedBattery(
name: String(localized: "sample.battery.rv.name", comment: "Sample data battery name for the adventure van system"),
nominalVoltage: 12.8,
capacityAmpHours: 200.0,
chemistry: .lithiumIronPhosphate,
chargeVoltage: 14.4,
cutOffVoltage: 10.8,
minimumTemperatureCelsius: -20,
maximumTemperatureCelsius: 60,
iconName: "battery.100.bolt",
colorName: "purple",
system: adventureVan
)
vanHouseBattery.timestamp = Date(timeIntervalSinceReferenceDate: 1250)
let workshopBackupBattery = SavedBattery(
name: String(localized: "sample.battery.workshop.name", comment: "Sample data battery name for the workshop system"),
nominalVoltage: 24.0,
capacityAmpHours: 100.0,
chemistry: .agm,
chargeVoltage: 28.8,
cutOffVoltage: 21.0,
minimumTemperatureCelsius: -10,
maximumTemperatureCelsius: 50,
iconName: "battery.75",
colorName: "gray",
system: workshopBench
)
workshopBackupBattery.timestamp = Date(timeIntervalSinceReferenceDate: 2300)
[vanHouseBattery, workshopBackupBattery].forEach { context.insert($0) }
let shoreCharger = SavedCharger(
name: String(localized: "sample.charger.shore.name", comment: "Sample data name for a shore power charger"),
inputVoltage: 230.0,
outputVoltage: 14.4,
maxCurrentAmps: 40.0,
maxPowerWatts: 600.0,
iconName: "powerplug",
colorName: "orange",
system: adventureVan,
identifier: "sample.charger.shore"
)
shoreCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1300)
let alternatorCharger = SavedCharger(
name: String(localized: "sample.charger.dcdc.name", comment: "Sample data name for a DC-DC charger"),
inputVoltage: 12.8,
outputVoltage: 14.2,
maxCurrentAmps: 30.0,
maxPowerWatts: 0.0,
iconName: "bolt.badge.clock",
colorName: "blue",
system: adventureVan,
identifier: "sample.charger.dcdc"
)
alternatorCharger.timestamp = Date(timeIntervalSinceReferenceDate: 1350)
let benchCharger = SavedCharger(
name: String(localized: "sample.charger.workbench.name", comment: "Sample data name for a workbench charger"),
inputVoltage: 120.0,
outputVoltage: 14.6,
maxCurrentAmps: 25.0,
maxPowerWatts: 365.0,
iconName: "bolt",
colorName: "green",
system: workshopBench,
identifier: "sample.charger.workbench"
)
benchCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2250)
[shoreCharger, alternatorCharger, benchCharger].forEach { context.insert($0) }
}
}
#endif

View File

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

View File

@@ -1,8 +1,138 @@
// Keys
"Add your first component" = "Erstelle deine erste Komponente";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Verbraucher sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
"Browse" = "Durchsuchen";
"Browse Library" = "Bibliothek durchsuchen";
"Browse electrical components from VoltPlan" = "Elektrische Verbraucher von VoltPlan durchstöbern";
"Cancel" = "Abbrechen";
"Changing the unit system will apply to all calculations in the app." = "Die Änderung der Einheiten wirkt sich auf alle Berechnungen der App aus.";
"Check back soon for new loads from VoltPlan." = "Schau bald wieder vorbei, um neue Verbraucher von VoltPlan zu finden.";
"Close" = "Schließen";
"Color" = "Farbe";
"Coming soon - manage your electrical systems and panels here." = "Demnächst verfügbar verwalte hier deine elektrischen Systeme und Verteilungen.";
"Component Library" = "Komponentenbibliothek";
"Components" = "Verbraucher";
"Create Component" = "Komponente erstellen";
"Create System" = "System erstellen";
"Create your first system" = "Erstelle dein erstes System";
"Current" = "Strom";
"Current Units" = "Aktuelle Einheiten";
"Details" = "Details";
"Details coming soon" = "Details folgen in Kürze";
"Edit Current" = "Strom bearbeiten";
"Edit Length" = "Länge bearbeiten";
"Edit Power" = "Leistung bearbeiten";
"Edit Voltage" = "Spannung bearbeiten";
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
"Enter length in %@" = "Gib die Länge in %@ ein";
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
"FUSE" = "SICHERUNG";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
"Icon" = "Symbol";
"Important:" = "Wichtig:";
"Length" = "Länge";
"Length:" = "Länge:";
"Load Library" = "Verbraucher-bibliothek";
"Loading components" = "Komponenten werden geladen";
"New Load" = "Neuer Verbraucher";
"No components available" = "Keine Komponenten verfügbar";
"No loads saved in this system yet." = "Dieses System hat noch keine Verbraucher.";
"No matches" = "Keine Treffer";
"Power" = "Leistung";
"Preview" = "Vorschau";
"Retry" = "Erneut versuchen";
"Safety Disclaimer" = "Sicherheitshinweis";
"Save" = "Speichern";
"Search components" = "Komponenten suchen";
"Settings" = "Einstellungen";
"System" = "System";
"System Name" = "Systemname";
"System View" = "Systemansicht";
"Systems" = "Systeme";
"This application provides electrical calculations for educational and estimation purposes only." = "Diese Anwendung erstellt elektrische Berechnungen zu Schulungszwecken.";
"Try searching for a different name." = "Versuche, nach einem anderen Namen zu suchen.";
"Unable to load components" = "Komponenten konnten nicht geladen werden";
"Unit System" = "Einheitensystem";
"Units" = "Einheiten";
"VoltPlan Library" = "VoltPlan-Bibliothek";
"Voltage" = "Spannung";
"WIRE" = "KABEL";
"Wire Cross-Section:" = "Kabelquerschnitt:";
"affiliate.button.review_parts" = "Bauteile prüfen";
"affiliate.description.with_link" = "Tippen oben zeigt eine vollständige Stückliste, bevor der Affiliate-Link geöffnet wird. Käufe können VoltPlan unterstützen.";
"affiliate.description.without_link" = "Tippen oben zeigt eine vollständige Stückliste mit Einkaufssuchen, die dir bei der Beschaffung helfen.";
"affiliate.disclaimer" = "Käufe über Affiliate-Links können VoltPlan unterstützen.";
"battery.bank.badge.capacity" = "Kapazität";
"battery.bank.badge.energy" = "Energie";
"battery.bank.badge.voltage" = "Spannung";
"battery.bank.banner.capacity" = "Kapazitätsabweichung erkannt";
"battery.bank.banner.voltage" = "Spannungsabweichung erkannt";
"battery.bank.empty.subtitle" = "Tippe auf Plus, um eine Batterie für %@ zu konfigurieren.";
"battery.bank.empty.title" = "Noch keine Batterien";
"battery.bank.header.title" = "Batterien";
"battery.bank.metric.capacity" = "Kapazität";
"battery.bank.metric.count" = "Batterien";
"battery.bank.metric.energy" = "Energie";
"battery.bank.metric.usable_capacity" = "Nutzbare Kapazität";
"battery.bank.metric.usable_energy" = "Nutzbare Energie";
"battery.bank.status.capacity.message" = "%@ nutzt eine andere Kapazität als der dominante Bankwert %@. Unterschiedliche Kapazitäten verursachen ungleichmäßige Entladung und vorzeitige Alterung.";
"battery.bank.status.capacity.title" = "Kapazitätsabweichung";
"battery.bank.status.dismiss" = "Verstanden";
"battery.bank.status.multiple.batteries" = "%d Batterien";
"battery.bank.status.single.battery" = "Eine Batterie";
"battery.bank.status.voltage.message" = "%@ weicht vom Bank-Basiswert %@ ab. Unterschiedliche Nennspannungen führen zu ungleichmäßigem Laden und können Ladegeräte oder Wechselrichter beschädigen.";
"battery.bank.status.voltage.title" = "Spannungsabweichung";
"battery.bank.warning.capacity.short" = "Kapazität";
"battery.bank.warning.voltage.short" = "Spannung";
"battery.editor.advanced.usable_capacity.footer_default" = "Standardwert %@ basierend auf der Chemie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Überschreibung aktiv. Chemie-Standard bleibt %@.";
"battery.editor.advanced.charge_voltage.helper" = "Lege die maximal empfohlene Ladespannung fest.";
"battery.editor.advanced.cutoff_voltage.helper" = "Lege die minimale sichere Entladespannung fest.";
"battery.editor.advanced.temperature_range.helper" = "Definiere den empfohlenen Betriebstemperaturbereich.";
"battery.editor.alert.charge_voltage.message" = "Gib die Ladespannung in Volt (V) ein.";
"battery.editor.alert.charge_voltage.placeholder" = "Ladespannung";
"battery.editor.alert.charge_voltage.title" = "Ladespannung bearbeiten";
"battery.editor.alert.cutoff_voltage.message" = "Gib die Abschaltspannung in Volt (V) ein.";
"battery.editor.alert.cutoff_voltage.placeholder" = "Abschaltspannung";
"battery.editor.alert.cutoff_voltage.title" = "Abschaltspannung bearbeiten";
"battery.editor.alert.maximum_temperature.message" = "Gib die Höchsttemperatur in Grad Celsius (\u00B0C) ein.";
"battery.editor.alert.maximum_temperature.placeholder" = "Höchsttemperatur (\u00B0C)";
"battery.editor.alert.maximum_temperature.title" = "Höchsttemperatur bearbeiten";
"battery.editor.alert.minimum_temperature.message" = "Gib die Mindesttemperatur in Grad Celsius (\u00B0C) ein.";
"battery.editor.alert.minimum_temperature.placeholder" = "Mindesttemperatur (\u00B0C)";
"battery.editor.alert.minimum_temperature.title" = "Mindesttemperatur bearbeiten";
"battery.editor.alert.cancel" = "Abbrechen";
"battery.editor.alert.capacity.message" = "Kapazität in Amperestunden (Ah) eingeben";
"battery.editor.alert.capacity.placeholder" = "Kapazität";
"battery.editor.alert.capacity.title" = "Kapazität bearbeiten";
"battery.editor.alert.save" = "Speichern";
"battery.editor.alert.usable_capacity.message" = "Nutzbare Kapazität in Prozent (%) eingeben";
"battery.editor.alert.usable_capacity.placeholder" = "Nutzbare Kapazität (%)";
"battery.editor.alert.usable_capacity.title" = "Nutzbare Kapazität bearbeiten";
"battery.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
"battery.editor.alert.voltage.placeholder" = "Spannung";
"battery.editor.alert.voltage.title" = "Nennspannung bearbeiten";
"battery.editor.button.reset_default" = "Zurücksetzen";
"battery.editor.cancel" = "Abbrechen";
"battery.editor.default_name" = "Neue Batterie";
"battery.editor.field.chemistry" = "Chemie";
"battery.editor.field.name" = "Name";
"battery.editor.placeholder.name" = "Hausbank";
"battery.editor.save" = "Speichern";
"battery.editor.section.advanced" = "Erweitert";
"battery.editor.section.summary" = "Übersicht";
"battery.editor.slider.capacity" = "Kapazität";
"battery.editor.slider.charge_voltage" = "Ladespannung";
"battery.editor.slider.cutoff_voltage" = "Abschaltspannung";
"battery.editor.slider.temperature_range" = "Temperaturbereich";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.usable_capacity" = "Nutzbare Kapazität (%)";
"battery.editor.slider.voltage" = "Nennspannung";
"battery.editor.title" = "Batterie einrichten";
"battery.onboarding.subtitle" = "Behalte Kapazität und Chemie deiner Batterien im Blick, um die Laufzeit im Griff zu behalten.";
"battery.onboarding.title" = "Füge deine erste Batterie hinzu";
"battery.overview.empty.create" = "Batterie hinzufügen";
"bom.accessibility.mark.complete" = "Markiere %@ als erledigt";
"bom.accessibility.mark.incomplete" = "Markiere %@ als unerledigt";
"bom.fuse.detail" = "Inline-Halter und %dA-Sicherung";
@@ -14,11 +144,93 @@
"bom.navigation.title.system" = "Stückliste %@";
"bom.size.unknown" = "Größe offen";
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
"bom.export.pdf.button" = "PDF exportieren";
"bom.export.pdf.error.title" = "Export fehlgeschlagen";
"bom.export.pdf.error.empty" = "Füge vor dem Export mindestens eine Komponente hinzu.";
"bom.pdf.header.title" = "System-Stückliste";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Einheitensystem: %@";
"bom.pdf.placeholder.empty" = "Keine Komponenten verfügbar.";
"bom.pdf.page.number" = "Seite %d";
"bom.category.components.title" = "Komponenten & Ladegeräte";
"bom.category.components.subtitle" = "Hauptverbraucher, Regler und Ladehardware.";
"bom.category.batteries.title" = "Batterien";
"bom.category.batteries.subtitle" = "Hausspeicher und Batteriebänke.";
"bom.category.cables.title" = "Kabel";
"bom.category.cables.subtitle" = "Passende Leitungen für jede Strecke.";
"bom.category.fuses.title" = "Sicherungen";
"bom.category.fuses.subtitle" = "Stromkreisschutz und Halter.";
"bom.category.accessories.title" = "Zubehör";
"bom.category.accessories.subtitle" = "Sicherungen, Kabelschuhe und weiteres Montagematerial.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"cable.pro.privacy.label" = "Datenschutz";
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
"cable.pro.terms.label" = "Nutzungsbedingungen";
"cable.pro.terms.url" = "https://voltplan.app/de/agb";
"calculator.advanced.duty_cycle.helper" = "Prozentsatz der aktiven Zeit, in der die Last tatsächlich Leistung aufnimmt.";
"calculator.advanced.duty_cycle.title" = "Einschaltdauer";
"calculator.advanced.section.title" = "Erweitert";
"calculator.advanced.usage_hours.helper" = "Stunden pro Tag, in denen die Last eingeschaltet ist.";
"calculator.advanced.usage_hours.title" = "Tägliche Laufzeit";
"calculator.advanced.usage_hours.unit" = "h/Tag";
"calculator.alert.duty_cycle.message" = "Einschaltdauer als Prozent (0-100 %) eingeben.";
"calculator.alert.duty_cycle.placeholder" = "Einschaltdauer";
"calculator.alert.duty_cycle.title" = "Einschaltdauer bearbeiten";
"calculator.alert.usage_hours.message" = "Stunden pro Tag eingeben, in denen die Last aktiv ist.";
"calculator.alert.usage_hours.placeholder" = "Tägliche Laufzeit";
"calculator.alert.usage_hours.title" = "Tägliche Laufzeit bearbeiten";
"charger.default.new" = "Neues Ladegerät";
"charger.editor.alert.cancel" = "Abbrechen";
"charger.editor.alert.current.message" = "Strom in Ampere (A) eingeben";
"charger.editor.alert.current.title" = "Ladestrom bearbeiten";
"charger.editor.alert.input_voltage.title" = "Eingangsspannung bearbeiten";
"charger.editor.alert.output_voltage.title" = "Ausgangsspannung bearbeiten";
"charger.editor.alert.power.message" = "Leistung in Watt (W) eingeben";
"charger.editor.alert.power.placeholder" = "Leistung";
"charger.editor.alert.power.title" = "Ladeleistung bearbeiten";
"charger.editor.alert.save" = "Speichern";
"charger.editor.alert.voltage.message" = "Spannung in Volt (V) eingeben";
"charger.editor.appearance.accessibility" = "Darstellung des Ladegeräts bearbeiten";
"charger.editor.appearance.subtitle" = "Passe an, wie dieses Ladegerät angezeigt wird";
"charger.editor.appearance.title" = "Ladegerät-Darstellung";
"charger.editor.default_name" = "Neues Ladegerät";
"charger.editor.field.current" = "Ladestrom";
"charger.editor.field.input_voltage" = "Eingangsspannung";
"charger.editor.field.name" = "Name";
"charger.editor.field.output_voltage" = "Ausgangsspannung";
"charger.editor.field.power" = "Ladeleistung";
"charger.editor.field.power.footer" = "Leer lassen, wenn keine Leistungsangabe vorliegt. Wir berechnen sie aus Spannung und Strom.";
"charger.editor.placeholder.name" = "Werkstattladegerät";
"charger.editor.section.electrical" = "Elektrik";
"charger.editor.section.power" = "Ladeausgang";
"charger.editor.title" = "Ladegerät";
"chargers.badge.current" = "Strom";
"chargers.badge.input" = "Eingang";
"chargers.badge.output" = "Ausgang";
"chargers.badge.power" = "Leistung";
"chargers.onboarding.primary" = "Ladegerät erstellen";
"chargers.onboarding.subtitle" = "Verwalte Landstrom, Booster und Solarregler, um deine Ladeleistung im Blick zu behalten.";
"chargers.onboarding.title" = "Füge deine Ladegeräte hinzu";
"chargers.subtitle" = "Ladegeräte-Komponenten sind bald verfügbar.";
"chargers.summary.metric.count" = "Ladegeräte";
"chargers.summary.metric.current" = "Ladestrom";
"chargers.summary.metric.output" = "Spannung";
"chargers.summary.metric.power" = "Ladeleistung";
"chargers.summary.title" = "Ladeübersicht";
"chargers.title" = "Ladegeräte für %@";
"component.fallback.name" = "Komponente";
"default.load.library" = "Bibliothekslast";
"default.load.name" = "Mein Verbraucher";
"default.load.unnamed" = "Unbenannter Verbraucher";
"default.load.new" = "Neuer Verbraucher";
"default.load.unnamed" = "Unbenannter Verbraucher";
"default.system.name" = "Mein System";
"default.system.new" = "Neues System";
"editor.load.name_field" = "Name des Verbrauchers";
@@ -27,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";

View File

@@ -14,6 +14,33 @@
"bom.navigation.title.system" = "Lista de materiales %@";
"bom.size.unknown" = "Tamaño por definir";
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
"bom.export.pdf.button" = "Exportar PDF";
"bom.export.pdf.error.title" = "Exportación fallida";
"bom.export.pdf.error.empty" = "Agrega al menos un componente antes de exportar.";
"bom.pdf.header.title" = "Lista de materiales del sistema";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Sistema de unidades: %@";
"bom.pdf.placeholder.empty" = "No hay componentes disponibles.";
"bom.pdf.page.number" = "Página %d";
"bom.category.components.title" = "Componentes y cargadores";
"bom.category.components.subtitle" = "Dispositivos principales, controladores y equipos de carga.";
"bom.category.batteries.title" = "Baterías";
"bom.category.batteries.subtitle" = "Bancos domésticos y almacenamiento.";
"bom.category.cables.title" = "Cables";
"bom.category.cables.subtitle" = "Tendidos dimensionados para cada circuito.";
"bom.category.fuses.title" = "Fusibles";
"bom.category.fuses.subtitle" = "Protección de circuitos y portafusibles.";
"bom.category.accessories.title" = "Accesorios";
"bom.category.accessories.subtitle" = "Fusibles, terminales y piezas de soporte.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"component.fallback.name" = "Componente";
"default.load.library" = "Carga de la biblioteca";
"default.load.name" = "Mi carga";
@@ -33,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";

View File

@@ -14,6 +14,33 @@
"bom.navigation.title.system" = "Liste de matériel %@";
"bom.size.unknown" = "Taille à déterminer";
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
"bom.empty.message" = "Aucun composant enregistré pour ce système pour linstant.";
"bom.export.pdf.button" = "Exporter en PDF";
"bom.export.pdf.error.title" = "Échec de lexport";
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant lexport.";
"bom.pdf.header.title" = "Liste de matériaux du système";
"bom.pdf.header.subtitle" = "%@ • %@";
"bom.pdf.header.inline" = "Système dunités : %@";
"bom.pdf.placeholder.empty" = "Aucun composant disponible.";
"bom.pdf.page.number" = "Page %d";
"bom.category.components.title" = "Composants et chargeurs";
"bom.category.components.subtitle" = "Appareils principaux, contrôleurs et équipements de charge.";
"bom.category.batteries.title" = "Batteries";
"bom.category.batteries.subtitle" = "Banques domestiques et stockage.";
"bom.category.cables.title" = "Câbles";
"bom.category.cables.subtitle" = "Liaisons dimensionnées pour chaque circuit.";
"bom.category.fuses.title" = "Fusibles";
"bom.category.fuses.subtitle" = "Protection des circuits et porte-fusibles.";
"bom.category.accessories.title" = "Accessoires";
"bom.category.accessories.subtitle" = "Fusibles, cosses et pièces complémentaires.";
"bom.cable.detail.quantified" = "%1$dx %2$@";
"bom.quantity.count.badge" = "%d×";
"bom.quantity.length.badge" = "%1$.1f %2$@";
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
"bom.quantity.fuse.badge" = "%1$d× · %2$d A";
"bom.quantity.terminal.badge" = "%1$d× · %2$@";
"bom.quantity.cable.badge" = "%1$.1f %2$@ · %3$@";
"bom.quantity.single.badge" = "1× • %@";
"component.fallback.name" = "Composant";
"default.load.library" = "Charge de la bibliothèque";
"default.load.name" = "Ma charge";
@@ -33,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 lautonomie.";
"overview.bom.title" = "Liste de matériel";
"overview.bom.subtitle" = "Touchez pour consulter les composants";
"overview.bom.unavailable" = "Ajoutez des charges pour générer des composants.";
"overview.bom.placeholder.short" = "Ajouter des charges";
"overview.chargetime.title" = "Temps de charge estimé";
"overview.chargetime.subtitle" = "Au débit de charge combiné";
"overview.chargetime.unavailable" = "Ajoutez des chargeurs et de la capacité batterie pour estimer.";
"overview.chargetime.placeholder.short" = "Ajouter des chargeurs";
"overview.goal.prefix" = "Objectif";
"overview.goal.label" = "Objectif %@";
"overview.goal.clear" = "Supprimer l'objectif";
"overview.goal.cancel" = "Annuler";
"overview.goal.save" = "Enregistrer";
"overview.runtime.goal.title" = "Objectif d'autonomie";
"overview.chargetime.goal.title" = "Objectif de recharge";
"overview.runtime.placeholder.short" = "Ajouter capacité";
"battery.bank.warning.voltage.short" = "Tension";
"battery.bank.warning.capacity.short" = "Capacité";
"battery.bank.header.title" = "Banque de batteries";
"battery.bank.metric.count" = "Batteries";
"battery.bank.metric.capacity" = "Capacité";
"battery.bank.metric.energy" = "Énergie";
"battery.bank.metric.usable_capacity" = "Capacité utilisable";
"battery.bank.metric.usable_energy" = "Énergie utilisable";
"battery.overview.empty.create" = "Ajouter une batterie";
"battery.onboarding.title" = "Ajoutez votre première batterie";
"battery.onboarding.subtitle" = "Suivez la capacité et la chimie de votre banc pour mieux maîtriser l'autonomie.";
"battery.bank.badge.voltage" = "Tension";
"overview.chargers.header.title" = "Vue densemble des chargeurs";
"overview.chargers.empty.title" = "Aucun chargeur configuré pour linstant";
"overview.chargers.empty.subtitle" = "Ajoutez des chargeurs secteur, DC-DC ou solaires pour comprendre votre capacité de charge.";
"overview.chargers.empty.create" = "Ajouter un chargeur";
"battery.bank.badge.capacity" = "Capacité";
"battery.bank.badge.energy" = "Énergie";
"battery.bank.banner.voltage" = "Écart de tension détecté";
"battery.bank.banner.capacity" = "Écart de capacité détecté";
"battery.bank.empty.title" = "Aucune batterie pour l'instant";
"battery.bank.empty.subtitle" = "Touchez le bouton plus pour configurer une batterie pour %@.";
"battery.bank.status.dismiss" = "Compris";
"battery.bank.status.single.battery" = "Une batterie";
"battery.bank.status.multiple.batteries" = "%d batteries";
"battery.bank.status.voltage.title" = "Écart de tension";
"battery.bank.status.voltage.message" = "%@ s'écarte de la valeur de référence %@ du banc. Mélanger des tensions nominales entraîne une charge inégale et peut endommager les chargeurs ou onduleurs connectés.";
"battery.bank.status.capacity.title" = "Écart de capacité";
"battery.bank.status.capacity.message" = "%@ utilise une capacité différente de la valeur dominante %@ du banc. Des capacités différentes provoquent des décharges inégales et une usure prématurée.";
"battery.editor.title" = "Configuration de la batterie";
"battery.editor.cancel" = "Annuler";
"battery.editor.save" = "Enregistrer";
"battery.editor.field.name" = "Nom";
"battery.editor.placeholder.name" = "Banque principale";
"battery.editor.field.chemistry" = "Chimie";
"battery.editor.section.summary" = "Résumé";
"battery.editor.slider.voltage" = "Tension nominale";
"battery.editor.slider.capacity" = "Capacité";
"battery.editor.slider.usable_capacity" = "Capacité utilisable (%)";
"battery.editor.slider.charge_voltage" = "Tension de charge";
"battery.editor.slider.cutoff_voltage" = "Tension de coupure";
"battery.editor.slider.temperature_range" = "Plage de température";
"battery.editor.slider.temperature_range.min" = "Minimum";
"battery.editor.slider.temperature_range.max" = "Maximum";
"battery.editor.section.advanced" = "Avancé";
"battery.editor.button.reset_default" = "Réinitialiser";
"battery.editor.advanced.usable_capacity.footer_default" = "Valeur par défaut %@ selon la chimie.";
"battery.editor.advanced.usable_capacity.footer_override" = "Remplacement actif. La valeur par défaut liée à la chimie reste %@.";
"battery.editor.advanced.charge_voltage.helper" = "Définissez la tension de charge maximale recommandée.";
"battery.editor.advanced.cutoff_voltage.helper" = "Définissez la tension minimale de décharge sûre.";
"battery.editor.advanced.temperature_range.helper" = "Définissez la plage de température de fonctionnement recommandée.";
"battery.editor.alert.voltage.title" = "Modifier la tension nominale";
"battery.editor.alert.voltage.placeholder" = "Tension";
"battery.editor.alert.voltage.message" = "Saisissez la tension en volts (V)";
"battery.editor.alert.capacity.title" = "Modifier la capacité";
"battery.editor.alert.capacity.placeholder" = "Capacité";
"battery.editor.alert.capacity.message" = "Saisissez la capacité en ampères-heures (Ah)";
"battery.editor.alert.usable_capacity.title" = "Modifier la capacité utilisable";
"battery.editor.alert.usable_capacity.placeholder" = "Capacité utilisable (%)";
"battery.editor.alert.usable_capacity.message" = "Saisissez le pourcentage de capacité utilisable (%)";
"battery.editor.alert.charge_voltage.title" = "Modifier la tension de charge";
"battery.editor.alert.charge_voltage.placeholder" = "Tension de charge";
"battery.editor.alert.charge_voltage.message" = "Saisissez la tension de charge en volts (V).";
"battery.editor.alert.cutoff_voltage.title" = "Modifier la tension de coupure";
"battery.editor.alert.cutoff_voltage.placeholder" = "Tension de coupure";
"battery.editor.alert.cutoff_voltage.message" = "Saisissez la tension de coupure en volts (V).";
"battery.editor.alert.minimum_temperature.title" = "Modifier la température minimale";
"battery.editor.alert.minimum_temperature.placeholder" = "Température minimale (\u00B0C)";
"battery.editor.alert.minimum_temperature.message" = "Saisissez la température minimale en degrés Celsius (\u00B0C).";
"battery.editor.alert.maximum_temperature.title" = "Modifier la température maximale";
"battery.editor.alert.maximum_temperature.placeholder" = "Température maximale (\u00B0C)";
"battery.editor.alert.maximum_temperature.message" = "Saisissez la température maximale en degrés Celsius (\u00B0C).";
"battery.editor.alert.cancel" = "Annuler";
"battery.editor.alert.save" = "Enregistrer";
"battery.editor.default_name" = "Nouvelle batterie";
"charger.editor.title" = "Chargeur";
"charger.editor.field.name" = "Nom";
"charger.editor.placeholder.name" = "Chargeur d'atelier";
"charger.editor.section.electrical" = "Électrique";
"charger.editor.section.power" = "Sortie de charge";
"charger.editor.appearance.title" = "Apparence du chargeur";
"charger.editor.appearance.subtitle" = "Personnalisez l'affichage de ce chargeur";
"charger.editor.appearance.accessibility" = "Modifier l'apparence du chargeur";
"charger.editor.field.input_voltage" = "Tension d'entrée";
"charger.editor.field.output_voltage" = "Tension de sortie";
"charger.editor.field.current" = "Courant de charge";
"charger.editor.field.power" = "Puissance de charge";
"charger.editor.field.power.footer" = "Laissez vide si la puissance nominale n'est pas indiquée. Nous la calculerons à partir de la tension et du courant.";
"charger.editor.default_name" = "Nouveau chargeur";
"charger.editor.alert.input_voltage.title" = "Modifier la tension d'entrée";
"charger.editor.alert.output_voltage.title" = "Modifier la tension de sortie";
"charger.editor.alert.current.title" = "Modifier le courant de charge";
"charger.editor.alert.voltage.message" = "Saisissez la tension en volts (V)";
"charger.editor.alert.power.title" = "Modifier la puissance de charge";
"charger.editor.alert.power.placeholder" = "Puissance";
"charger.editor.alert.power.message" = "Saisissez la puissance en watts (W)";
"charger.editor.alert.current.message" = "Saisissez le courant en ampères (A)";
"charger.editor.alert.cancel" = "Annuler";
"charger.editor.alert.save" = "Enregistrer";
"charger.default.new" = "Nouveau chargeur";
"chargers.summary.title" = "Aperçu de charge";
"chargers.summary.metric.count" = "Chargeurs";
"chargers.summary.metric.output" = "Tension de sortie";
"chargers.summary.metric.current" = "Courant de charge";
"chargers.summary.metric.power" = "Puissance de charge";
"chargers.badge.input" = "Entrée";
"chargers.badge.output" = "Sortie";
"chargers.badge.current" = "Courant";
"chargers.badge.power" = "Puissance";
"chargers.onboarding.title" = "Ajoutez vos chargeurs";
"chargers.onboarding.subtitle" = "Suivez l'alimentation quai, les boosters et les régulateurs solaires pour connaître votre capacité de charge.";
"chargers.onboarding.primary" = "Créer un chargeur";
"sample.battery.rv.name" = "Batterie de service LiFePO4";
"sample.battery.workshop.name" = "Batterie de secours de l'établi";
"sample.charger.shore.name" = "Chargeur de quai";
"sample.charger.dcdc.name" = "Chargeur DC-DC";
"sample.charger.workbench.name" = "Chargeur d'établi";
"chargers.title" = "Chargeurs pour %@";
"chargers.subtitle" = "Les chargeurs seront bientôt disponibles ici.";
"cable.pro.paywall.title" = "Cable PRO";
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
"cable.pro.feature.usageBased" = "Calculs basés sur lutilisation";
"generic.ok" = "OK";

View File

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

View File

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

View File

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

View File

@@ -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 its 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.
let app = XCUIApplication()
app.launch()
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")
// Use XCTAssert and related functions to verify your tests produce the correct results.
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 testLaunchPerformance() throws {
// // This measures how long it takes to launch your application.
// measure(metrics: [XCTApplicationLaunchMetric()]) {
// XCUIApplication().launch()
// }
// }
@MainActor
func testSampleDataScreenshots() throws {
let app = XCUIApplication()
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
app.launch()
let systemsList = resolvedSystemsList(in: app)
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
takeScreenshot(named: "05-SystemsWithSampleData")
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
let systemName = firstSystemCell.staticTexts.firstMatch.label
let systemButton = firstSystemCell.buttons.firstMatch
if systemButton.exists {
systemButton.tap()
} else {
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
var detailVisible = waitForSystemDetail(named: systemName, in: app)
if !detailVisible {
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
detailVisible = waitForSystemDetail(named: systemName, in: app)
}
XCTAssertTrue(detailVisible)
takeScreenshot(named: "06-AdventureVanOverview")
// let overviewTab = app.buttons["overview-tab"]
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
// overviewTab.tap()
waitForStability(long: false)
let bomElement = resolveBillOfMaterialsElement(in: app)
if !bomElement.waitForExistence(timeout: 6) {
bringElementIntoView(bomElement, in: app)
}
XCTAssertTrue(bomElement.exists)
if !bomElement.isHittable {
bringElementIntoView(bomElement, in: app, requireHittable: true)
}
if bomElement.isHittable {
bomElement.tap()
} else {
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
waitForStability(long: true)
takeScreenshot(named: "08-BillOfMaterials")
let closeButton = app.buttons["system-bom-close-button"]
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
closeButton.tap()
let componentsTab = componentsTabButton(in: app)
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
if componentsTab.isHittable {
componentsTab.tap()
} else {
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
}
let loadsList = resolvedLoadsList(in: app)
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
takeScreenshot(named: "07-AdventureVanLoads")
waitForStability()
let firstLoad = loadsList.cells.element(boundBy: 0)
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
let loadName = firstLoad.staticTexts.firstMatch.label
firstLoad.tap()
let loadNavButton = app.navigationBars.buttons[loadName]
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
takeScreenshot(named: "09-AdventureVanCalculator")
}
private func launchApp(arguments: [String]) -> XCUIApplication {
let app = XCUIApplication()
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
//dismissSystemOverlays()
return app
}
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
return app.collectionViews.firstMatch
}
let table = app.tables["systems-list"]
if table.waitForExistence(timeout: 6) {
return table
}
XCTAssertTrue(app.tables.firstMatch.waitForExistence(timeout: 2))
return app.tables.firstMatch
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(named name: String) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
private func waitForStability(long: Bool = false) {
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
}
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
let identifierMatch = app.descendants(matching: .any)
.matching(identifier: "components-tab").firstMatch
if identifierMatch.exists {
return identifierMatch
}
let localizedLabels = [
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
]
for label in localizedLabels {
let button = app.buttons[label]
if button.exists {
return button
}
let tabBarButton = app.tabBars.buttons[label]
if tabBarButton.exists {
return tabBarButton
}
let segmentedButton = app.segmentedControls.buttons[label]
if segmentedButton.exists {
return segmentedButton
}
let segmentedOther = app.segmentedControls.otherElements[label]
if segmentedOther.exists {
return segmentedOther
}
}
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
if fallbackSegmented.exists {
return fallbackSegmented
}
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
if tabBarButton.exists {
return tabBarButton
}
return app.tabBars.descendants(matching: .any).firstMatch
}
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if app.otherElements["system-overview"].exists {
return true
}
let navBar = app.navigationBars.firstMatch
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
}
return app.otherElements["system-overview"].exists
}
private func bringElementIntoView(
_ element: XCUIElement,
in app: XCUIApplication,
requireHittable: Bool = false,
attempts: Int = 8
) {
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.tables.firstMatch
for _ in 0..<attempts {
if element.exists, (!requireHittable || element.isHittable) {
return
}
if scrollContainer.exists {
scrollContainer.swipeUp()
} else {
app.swipeUp()
}
waitForStability()
_ = element.waitForExistence(timeout: 2)
}
}
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
let identifier = "system-bom-button"
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
if buttonByIdentifier.exists { return buttonByIdentifier }
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
if elementByIdentifier.exists { return elementByIdentifier }
let candidates = candidateStrings(for: .billOfMaterials)
for candidate in candidates {
let button = app.buttons[candidate]
if button.exists {
return button
}
let other = app.otherElements[candidate]
if other.exists {
return other
}
}
return buttonByIdentifier
}
private func dismissNotificationBannersIfNeeded() {
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
if banner.waitForExistence(timeout: 1) {
if banner.isHittable {
banner.swipeUp()
} else {
let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -1.5))
start.press(forDuration: 0.05, thenDragTo: end)
}
waitForStability()
}
}
private func candidateStrings(for key: UIStringKey) -> [String] {
var values = Set<String>()
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
let localized = translations[key]?[languageCode] {
values.insert(localized)
}
if let english = translations[key]?["en"] {
values.insert(english)
}
if let others = translations[key]?.values {
values.formUnion(others)
}
if key == .settings {
values.insert("gearshape")
}
return Array(values)
}
private func button(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let element = query[candidate]
if element.exists {
return element
}
}
let predicate = NSPredicate(
format: "label IN %@ OR identifier IN %@",
NSArray(array: candidates),
NSArray(array: candidates)
)
return query.matching(predicate).firstMatch
}
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
let element = button(in: query, for: key)
return element.exists ? element : nil
}
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let tabSpecific = button(in: app.tabBars.buttons, for: key)
if tabSpecific.exists {
return tabSpecific
}
return button(in: app.buttons, for: key)
}
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let bar = app.navigationBars[candidate]
if bar.exists {
return bar
}
}
return app.navigationBars.element(boundBy: 0)
}
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
let candidates = candidateStrings(for: key)
for candidate in candidates {
let button = app.buttons[candidate]
if button.waitForExistence(timeout: 2) {
button.tap()
return
}
}
}
private func openBillOfMaterials(app: XCUIApplication) {
let bomButton = button(in: app.buttons, for: .billOfMaterials)
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
waitForStability(long: true)
}
private func closeBillOfMaterials(app: XCUIApplication) {
tapButtonIfPresent(app: app, key: .close)
}
private func navigateBack(app: XCUIApplication) {
let backButton = app.navigationBars.buttons.element(boundBy: 0)
if backButton.exists {
backButton.tap()
} else {
app.swipeRight()
}
}
private func openSettings(app: XCUIApplication) {
let systemsBar = navigationBar(in: app, key: .systemsTitle)
let settingsButton = button(in: systemsBar.buttons, for: .settings)
if settingsButton.exists {
settingsButton.tap()
} else {
systemsBar.buttons.element(boundBy: 0).tap()
}
}
private func ensureDoNotDisturbEnabled() {
springboard.activate()
let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02))
let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30))
pullStart.press(forDuration: 0.1, thenDragTo: pullEnd)
let focusTile = springboard.otherElements["Focus"]
let focusButton = springboard.buttons["Focus"]
if focusTile.waitForExistence(timeout: 2) {
focusTile.press(forDuration: 1.0)
} else if focusButton.waitForExistence(timeout: 2) {
focusButton.press(forDuration: 1.0)
} else {
return
}
let dndButton = springboard.buttons["Do Not Disturb"]
if dndButton.waitForExistence(timeout: 1) {
if !dndButton.isSelected {
dndButton.tap()
}
} else {
let dndCell = springboard.cells["Do Not Disturb"]
if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected {
dndCell.tap()
} else {
let dndLabel = springboard.staticTexts["Do Not Disturb"]
if dndLabel.waitForExistence(timeout: 1) {
dndLabel.tap()
}
}
}
let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
}
private func dismissSystemOverlays() {
let app = XCUIApplication()
let alertButtons = [
"OK", "Allow", "Later", "Not Now", "Close",
"Continue", "Remind Me Later", "Maybe Later",
]
if app.alerts.firstMatch.exists {
handleAlerts(in: app, buttons: alertButtons)
}
if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists {
handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"])
}
}
private func handleAlerts(in application: XCUIApplication, buttons: [String]) {
for buttonLabel in buttons {
let button = application.buttons[buttonLabel]
if button.waitForExistence(timeout: 0.5) {
button.tap()
return
}
}
let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch
if closeButton.exists {
closeButton.tap()
}
}
}

View File

@@ -8,44 +8,159 @@
import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase {
override class var runsForEachTargetApplicationUIConfiguration: Bool {
false
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
private func launchApp(arguments: [String] = []) -> XCUIApplication {
let app = XCUIApplication()
setupSnapshot(app)
var launchArguments = ["--uitest-reset-data"]
launchArguments.append(contentsOf: arguments)
app.launchArguments = launchArguments
app.launch()
return app
}
// 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
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["systems-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
let table = app.tables["systems-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
let collection = app.collectionViews["loads-list"]
if collection.waitForExistence(timeout: 6) {
return collection
}
let table = app.tables["loads-list"]
XCTAssertTrue(table.waitForExistence(timeout: 6))
return table
}
private func takeScreenshot(name: String,
lifetime: XCTAttachment.Lifetime = .keepAlways) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = lifetime
add(attachment)
}
override class var runsForEachTargetApplicationUIConfiguration: Bool {
false
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testOnboardingLoadsView() throws {
let app = 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")
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()
snapshot("1OnboardingLoadsView")
let createComponentButton = app.buttons["create-component-button"]
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)
}
}

View File

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

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

BIN
Shots/Fonts/Inter-Bold.otf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

7
Shots/Titles/de.conf Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

BIN
Shots/frame-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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 simulators black edge pixels
SHADOW_OPACITY=0 # 0100
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
View 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