Compare commits
10 Commits
0a2789dc44
...
296cf63176
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
296cf63176 | ||
|
|
16fd491af5 | ||
|
|
7c5c4dff5c | ||
|
|
cb628277fb | ||
|
|
03aa843f26 | ||
|
|
2f0cebceed | ||
|
|
ab5e3e14ac | ||
|
|
a35ad49a58 | ||
|
|
0842815133 | ||
|
|
5fb8997ab9 |
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_*
|
||||
fastlane/screenshots
|
||||
xcshareddata
|
||||
|
Before Width: | Height: | Size: 146 KiB |
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:0.66422,0.66424,0.66423,1.00000",
|
||||
"extended-gray:1.00000,1.00000"
|
||||
]
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "cablebyvoltplan-logomark copy.png",
|
||||
"name" : "cablebyvoltplan-logomark copy",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
-483.8671875,
|
||||
391.375
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */ = {isa = PBXBuildFile; fileRef = 3E4BC9B72E7F5E9E0052324A /* Cable.icon */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 3E5C0BC42E72C0FD00247EC8 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 3E5C0BCB2E72C0FD00247EC8;
|
||||
remoteInfo = Cable;
|
||||
};
|
||||
3E5C0BDE2E72C0FE00247EC8 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 3E5C0BC42E72C0FD00247EC8 /* Project object */;
|
||||
@@ -28,7 +31,7 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
3E4BC9B72E7F5E9E0052324A /* Cable.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = Cable.icon; sourceTree = "<group>"; };
|
||||
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITestsScreenshot.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cable.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CableUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
@@ -45,6 +48,11 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = CableUITestsScreenshot;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3E5C0BCE2E72C0FD00247EC8 /* Cable */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
@@ -66,6 +74,13 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
3E37F6522E93FB6F00836187 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3E5C0BC92E72C0FD00247EC8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -96,8 +111,8 @@
|
||||
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
||||
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||
3E4BC9B72E7F5E9E0052324A /* Cable.icon */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -107,6 +122,7 @@
|
||||
3E5C0BCC2E72C0FD00247EC8 /* Cable.app */,
|
||||
3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */,
|
||||
3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */,
|
||||
3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -114,6 +130,29 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */;
|
||||
buildPhases = (
|
||||
3E37F6512E93FB6F00836187 /* Sources */,
|
||||
3E37F6522E93FB6F00836187 /* Frameworks */,
|
||||
3E37F6532E93FB6F00836187 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
3E37F65C2E93FB6F00836187 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||
);
|
||||
name = CableUITestsScreenshot;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = CableUITestsScreenshot;
|
||||
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
3E5C0BCB2E72C0FD00247EC8 /* Cable */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 3E5C0BF02E72C0FE00247EC8 /* Build configuration list for PBXNativeTarget "Cable" */;
|
||||
@@ -189,9 +228,13 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1640;
|
||||
LastUpgradeCheck = 1640;
|
||||
LastSwiftUpdateCheck = 2600;
|
||||
LastUpgradeCheck = 2600;
|
||||
TargetAttributes = {
|
||||
3E37F6542E93FB6F00836187 = {
|
||||
CreatedOnToolsVersion = 26.0.1;
|
||||
TestTargetID = 3E5C0BCB2E72C0FD00247EC8;
|
||||
};
|
||||
3E5C0BCB2E72C0FD00247EC8 = {
|
||||
CreatedOnToolsVersion = 16.4;
|
||||
};
|
||||
@@ -211,6 +254,10 @@
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
de,
|
||||
es,
|
||||
fr,
|
||||
nl,
|
||||
);
|
||||
mainGroup = 3E5C0BC32E72C0FD00247EC8;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
@@ -222,16 +269,23 @@
|
||||
3E5C0BCB2E72C0FD00247EC8 /* Cable */,
|
||||
3E5C0BDC2E72C0FE00247EC8 /* CableTests */,
|
||||
3E5C0BE62E72C0FE00247EC8 /* CableUITests */,
|
||||
3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3E37F6532E93FB6F00836187 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3E5C0BCA2E72C0FD00247EC8 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3E4BC9B82E7F5E9E0052324A /* Cable.icon in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -252,6 +306,13 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
3E37F6512E93FB6F00836187 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3E5C0BC82E72C0FD00247EC8 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -276,6 +337,11 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
3E37F65C2E93FB6F00836187 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
||||
targetProxy = 3E37F65B2E93FB6F00836187 /* PBXContainerItemProxy */;
|
||||
};
|
||||
3E5C0BDF2E72C0FE00247EC8 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
||||
@@ -289,6 +355,48 @@
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
3E37F65E2E93FB6F00836187 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuzuhub.CableUITestsScreenshot;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = Cable;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
3E37F65F2E93FB6F00836187 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.yuzuhub.CableUITestsScreenshot;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_TARGET_NAME = Cable;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
3E5C0BF12E72C0FE00247EC8 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -296,9 +404,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Cable/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
@@ -311,7 +421,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -327,9 +437,11 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = Cable/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
|
||||
@@ -342,7 +454,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 0.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -410,6 +522,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -467,6 +580,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
@@ -547,6 +661,15 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
3E37F65D2E93FB6F00836187 /* Build configuration list for PBXNativeTarget "CableUITestsScreenshot" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3E37F65E2E93FB6F00836187 /* Debug */,
|
||||
3E37F65F2E93FB6F00836187 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
3E5C0BC72E72C0FD00247EC8 /* Build configuration list for PBXProject "Cable" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -9,6 +9,19 @@
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
<key>CableScreenshots.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>3E5C0BCB2E72C0FD00247EC8</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
BIN
Cable/AppIcon.icon/Assets/box-2.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
Cable/AppIcon.icon/Assets/voltplan-lines.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Cable/AppIcon.icon/Assets/voltplan-logo 2 2.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
73
Cable/AppIcon.icon/icon.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.50588,0.79216,0.56471,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"image-name" : "voltplan-logo 2 2.png",
|
||||
"name" : "voltplan-logo 2 2",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Icon1024_opaque.png",
|
||||
"filename" : "Cable-iOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -5,11 +5,42 @@
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "voltplan-logo-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
||||
BIN
Cable/Assets.xcassets/PoweredByVoltplan.imageset/voltplan-logo-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 85 KiB |
52
Cable/Assets.xcassets/boat-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "boat-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "boat-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/boat-onboarding.imageset/boat-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
Cable/Assets.xcassets/boat-onboarding.imageset/boat-light.png
vendored
Normal file
|
After Width: | Height: | Size: 145 KiB |
52
Cable/Assets.xcassets/cabin-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "cabin-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "cabin-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
Cable/Assets.xcassets/cabin-onboarding.imageset/cabin-light.png
vendored
Normal file
|
After Width: | Height: | Size: 108 KiB |
52
Cable/Assets.xcassets/charger-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "charger-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "charger-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/charger-onboarding.imageset/charger-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
Cable/Assets.xcassets/charger-onboarding.imageset/charger-light.png
vendored
Normal file
|
After Width: | Height: | Size: 79 KiB |
52
Cable/Assets.xcassets/coffee-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "coffee-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "coffee-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/coffee-onboarding.imageset/coffee-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
Cable/Assets.xcassets/coffee-onboarding.imageset/coffee-light.png
vendored
Normal file
|
After Width: | Height: | Size: 81 KiB |
52
Cable/Assets.xcassets/router-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "router-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "router-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/router-onboarding.imageset/router-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
Cable/Assets.xcassets/router-onboarding.imageset/router-light.png
vendored
Normal file
|
After Width: | Height: | Size: 74 KiB |
52
Cable/Assets.xcassets/van-onboarding.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "bus-light.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "bus-dark.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Cable/Assets.xcassets/van-onboarding.imageset/bus-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
Cable/Assets.xcassets/van-onboarding.imageset/bus-light.png
vendored
Normal file
|
After Width: | Height: | Size: 122 KiB |
37
Cable/Base.lproj/Localizable.strings
Normal file
@@ -0,0 +1,37 @@
|
||||
"affiliate.button.review_parts" = "Review parts";
|
||||
"affiliate.description.with_link" = "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.";
|
||||
"affiliate.description.without_link" = "Tapping above shows a full bill of materials with shopping searches to help you source parts.";
|
||||
"affiliate.disclaimer" = "Purchases through affiliate links may support VoltPlan.";
|
||||
"bom.accessibility.mark.complete" = "Mark %@ complete";
|
||||
"bom.accessibility.mark.incomplete" = "Mark %@ incomplete";
|
||||
"bom.fuse.detail" = "Inline holder and %dA fuse";
|
||||
"bom.item.cable.black" = "Power Cable (Black)";
|
||||
"bom.item.cable.red" = "Power Cable (Red)";
|
||||
"bom.item.fuse" = "Fuse & Holder";
|
||||
"bom.item.terminals" = "Cable Shoes / Terminals";
|
||||
"bom.navigation.title" = "Bill of Materials";
|
||||
"bom.navigation.title.system" = "BOM – %@";
|
||||
"bom.size.unknown" = "Size TBD";
|
||||
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
||||
"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.system.name" = "My System";
|
||||
"default.system.new" = "New System";
|
||||
"editor.load.name_field" = "Load name";
|
||||
"editor.load.preview" = "Preview";
|
||||
"editor.load.title" = "Edit Load";
|
||||
"editor.system.location.optional" = "Location (optional)";
|
||||
"editor.system.name_field" = "System name";
|
||||
"editor.system.title" = "Edit System";
|
||||
"slider.button.ampere" = "Ampere";
|
||||
"slider.button.watt" = "Watt";
|
||||
"slider.current.title" = "Current";
|
||||
"slider.length.title" = "Cable Length (%@)";
|
||||
"slider.power.title" = "Power";
|
||||
"slider.voltage.title" = "Voltage";
|
||||
"system.list.no.components" = "No components yet";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Metric (mm², m)";
|
||||
22
Cable/Base.lproj/Localizable.stringsdict
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>system.list.component.summary</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@component_count@ • %@</string>
|
||||
<key>component_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d component</string>
|
||||
<key>other</key>
|
||||
<string>%d components</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -8,9 +8,5 @@
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -13,7 +13,7 @@ class CableCalculator: ObservableObject {
|
||||
@Published var current: Double = 5.0
|
||||
@Published var power: Double = 60.0
|
||||
@Published var length: Double = 10.0
|
||||
@Published var loadName: String = "My Load"
|
||||
@Published var loadName: String = String(localized: "default.load.name", comment: "Default placeholder name for a load")
|
||||
|
||||
var calculatedPower: Double {
|
||||
voltage * current
|
||||
|
||||
@@ -193,7 +193,7 @@ struct CalculatorView: View {
|
||||
loadConfiguration(from: savedLoad)
|
||||
}
|
||||
}
|
||||
.onChange(of: completedItemIDs) { _ in
|
||||
.onChange(of: completedItemIDs) { _, _ in
|
||||
persistCompletedItems()
|
||||
}
|
||||
}
|
||||
@@ -237,11 +237,11 @@ struct CalculatorView: View {
|
||||
affiliateURL = nil
|
||||
}
|
||||
|
||||
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.regionCode
|
||||
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
let countryCode = rawCountryCode?.uppercased()
|
||||
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
||||
|
||||
let buttonTitle = regionName.map { "Review parts for \($0)" } ?? "Review parts"
|
||||
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
|
||||
let identifier = "bom-\(savedLoad.name)-\(savedLoad.timestamp.timeIntervalSince1970)"
|
||||
|
||||
return AffiliateLinkInfo(
|
||||
@@ -504,10 +504,6 @@ struct CalculatorView: View {
|
||||
@ViewBuilder
|
||||
private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Need the hardware?")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Button {
|
||||
presentedAffiliateLink = info
|
||||
} label: {
|
||||
@@ -524,13 +520,14 @@ struct CalculatorView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if let regionName = info.regionName {
|
||||
Text("Configured for \(regionName).")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(info.affiliateURL != nil ? "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan." : "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
|
||||
let descriptionKey = info.affiliateURL != nil
|
||||
? "affiliate.description.with_link"
|
||||
: "affiliate.description.without_link"
|
||||
let description = NSLocalizedString(
|
||||
descriptionKey,
|
||||
comment: "Explanation text beneath the affiliate button"
|
||||
)
|
||||
Text(description)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@@ -547,19 +544,36 @@ struct CalculatorView: View {
|
||||
|
||||
let crossSectionValue = calculator.crossSection(for: unitSystem)
|
||||
let crossSectionLabel: String
|
||||
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is unknown")
|
||||
if unitSystem == .imperial {
|
||||
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue)
|
||||
if crossSectionValue > 0 {
|
||||
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue)
|
||||
} else {
|
||||
crossSectionLabel = unknownSizeLabel
|
||||
}
|
||||
} else {
|
||||
crossSectionLabel = String(format: "%.1f mm²", crossSectionValue)
|
||||
if crossSectionValue > 0 {
|
||||
crossSectionLabel = String(format: "%.1f mm²", crossSectionValue)
|
||||
} else {
|
||||
crossSectionLabel = unknownSizeLabel
|
||||
}
|
||||
}
|
||||
|
||||
let cableDetail = "\(lengthLabel) • \(crossSectionLabel)"
|
||||
let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage)
|
||||
|
||||
let fuseRating = calculator.recommendedFuse
|
||||
let fuseDetail = "Inline holder and \(fuseRating)A fuse"
|
||||
let fuseDetailFormat = NSLocalizedString(
|
||||
"bom.fuse.detail",
|
||||
comment: "Description for the fuse entry in the calculator BOM"
|
||||
)
|
||||
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
|
||||
|
||||
let cableShoesDetail = "Ring or spade terminals sized for \(crossSectionLabel) wiring"
|
||||
let cableShoesDetailFormat = NSLocalizedString(
|
||||
"bom.terminals.detail",
|
||||
comment: "Description for the cable terminals entry in the calculator BOM"
|
||||
)
|
||||
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
|
||||
|
||||
let cableGaugeQuery: String
|
||||
if unitSystem == .imperial {
|
||||
@@ -578,10 +592,12 @@ struct CalculatorView: View {
|
||||
|
||||
var items: [BOMItem] = []
|
||||
|
||||
let fallbackComponentTitle = String(localized: "component.fallback.name", comment: "Fallback name for a component when no custom name is provided")
|
||||
|
||||
items.append(
|
||||
BOMItem(
|
||||
id: "component",
|
||||
title: calculator.loadName.isEmpty ? "Component" : calculator.loadName,
|
||||
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase),
|
||||
@@ -592,7 +608,7 @@ struct CalculatorView: View {
|
||||
items.append(
|
||||
BOMItem(
|
||||
id: "cable-red",
|
||||
title: "Power Cable (Red)",
|
||||
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
@@ -603,7 +619,7 @@ struct CalculatorView: View {
|
||||
items.append(
|
||||
BOMItem(
|
||||
id: "cable-black",
|
||||
title: "Power Cable (Black)",
|
||||
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
@@ -614,7 +630,7 @@ struct CalculatorView: View {
|
||||
items.append(
|
||||
BOMItem(
|
||||
id: "fuse",
|
||||
title: "Fuse & Holder",
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
@@ -625,7 +641,7 @@ struct CalculatorView: View {
|
||||
items.append(
|
||||
BOMItem(
|
||||
id: "terminals",
|
||||
title: "Cable Shoes / Terminals",
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
@@ -646,7 +662,7 @@ struct CalculatorView: View {
|
||||
}
|
||||
|
||||
private var voltageSlider: some View {
|
||||
SliderSection(title: "Voltage",
|
||||
SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"),
|
||||
value: $calculator.voltage,
|
||||
range: 3...48,
|
||||
unit: "V",
|
||||
@@ -666,11 +682,11 @@ struct CalculatorView: View {
|
||||
@ViewBuilder
|
||||
private var currentPowerSlider: some View {
|
||||
if isWattMode {
|
||||
SliderSection(title: "Power",
|
||||
SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"),
|
||||
value: $calculator.power,
|
||||
range: 0...2000,
|
||||
unit: "W",
|
||||
buttonText: "Watt",
|
||||
buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"),
|
||||
buttonAction: {
|
||||
isWattMode = false
|
||||
calculator.updateFromPower()
|
||||
@@ -684,11 +700,11 @@ struct CalculatorView: View {
|
||||
autoUpdateSavedLoad()
|
||||
}
|
||||
} else {
|
||||
SliderSection(title: "Current",
|
||||
SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"),
|
||||
value: $calculator.current,
|
||||
range: 0...100,
|
||||
unit: "A",
|
||||
buttonText: "Ampere",
|
||||
buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"),
|
||||
buttonAction: {
|
||||
isWattMode = true
|
||||
calculator.updateFromCurrent()
|
||||
@@ -705,7 +721,11 @@ struct CalculatorView: View {
|
||||
}
|
||||
|
||||
private var lengthSlider: some View {
|
||||
SliderSection(title: "Cable Length (\(unitSettings.unitSystem.lengthUnit))",
|
||||
let lengthTitleFormat = NSLocalizedString(
|
||||
"slider.length.title",
|
||||
comment: "Title format for the cable length slider"
|
||||
)
|
||||
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
|
||||
value: $calculator.length,
|
||||
range: 0...20,
|
||||
unit: unitSettings.unitSystem.lengthUnit,
|
||||
@@ -721,8 +741,9 @@ struct CalculatorView: View {
|
||||
|
||||
|
||||
private func saveCurrentLoad() {
|
||||
let fallbackName = String(localized: "default.load.unnamed", comment: "Fallback name for a load when no name is provided")
|
||||
let savedLoad = SavedLoad(
|
||||
name: calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName,
|
||||
name: calculator.loadName.isEmpty ? fallbackName : calculator.loadName,
|
||||
voltage: calculator.voltage,
|
||||
current: calculator.current,
|
||||
power: calculator.power,
|
||||
@@ -750,7 +771,8 @@ struct CalculatorView: View {
|
||||
private func autoUpdateSavedLoad() {
|
||||
guard let savedLoad = savedLoad else { return }
|
||||
|
||||
savedLoad.name = calculator.loadName.isEmpty ? "Unnamed Load" : calculator.loadName
|
||||
let fallbackName = String(localized: "default.load.unnamed", comment: "Fallback name for a load when no name is provided")
|
||||
savedLoad.name = calculator.loadName.isEmpty ? fallbackName : calculator.loadName
|
||||
savedLoad.voltage = calculator.voltage
|
||||
savedLoad.current = calculator.current
|
||||
savedLoad.power = calculator.power
|
||||
@@ -786,6 +808,21 @@ private struct BillOfMaterialsView: View {
|
||||
ForEach(items) { item in
|
||||
let isCompleted = completedItemIDs.contains(item.id)
|
||||
let destinationURL = destinationURL(for: item)
|
||||
let accessibilityLabel: String = {
|
||||
if isCompleted {
|
||||
let format = NSLocalizedString(
|
||||
"bom.accessibility.mark.incomplete",
|
||||
comment: "Accessibility label to mark a BOM item incomplete"
|
||||
)
|
||||
return String.localizedStringWithFormat(format, item.title)
|
||||
} else {
|
||||
let format = NSLocalizedString(
|
||||
"bom.accessibility.mark.complete",
|
||||
comment: "Accessibility label to mark a BOM item complete"
|
||||
)
|
||||
return String.localizedStringWithFormat(format, item.title)
|
||||
}
|
||||
}()
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
||||
@@ -799,7 +836,7 @@ private struct BillOfMaterialsView: View {
|
||||
}
|
||||
suppressRowTapForID = item.id
|
||||
}
|
||||
.accessibilityLabel(isCompleted ? "Mark \(item.title) incomplete" : "Mark \(item.title) complete")
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
@@ -808,7 +845,7 @@ private struct BillOfMaterialsView: View {
|
||||
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
|
||||
|
||||
if item.isPrimaryComponent {
|
||||
Text("Component")
|
||||
Text(String(localized: "component.fallback.name", comment: "Tag label marking a BOM entry as the main component"))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
@@ -850,14 +887,24 @@ private struct BillOfMaterialsView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(info.affiliateURL != nil ? "Purchases through the affiliate link may support VoltPlan." : "Amazon searches are provided to help you source comparable parts.")
|
||||
Text(
|
||||
String(
|
||||
localized: "affiliate.disclaimer",
|
||||
comment: "Footer note reminding users that affiliate purchases may support the app"
|
||||
)
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("Bill of Materials")
|
||||
.navigationTitle(
|
||||
String(
|
||||
localized: "bom.navigation.title",
|
||||
comment: "Navigation title for the bill of materials view"
|
||||
)
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
|
||||
@@ -40,7 +40,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
var primaryAffiliateLink: AffiliateLink? {
|
||||
affiliateLink(matching: Locale.current.regionCode)
|
||||
affiliateLink(matching: Locale.current.region?.identifier)
|
||||
}
|
||||
|
||||
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
|
||||
@@ -101,29 +101,55 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func fetchComponents() async throws -> [ComponentLibraryItem] {
|
||||
var components = URLComponents(url: baseURL.appendingPathComponent("api/collections/components/records"), resolvingAgainstBaseURL: false)
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "filter", value: "(type='load')"),
|
||||
URLQueryItem(name: "sort", value: "+name"),
|
||||
URLQueryItem(name: "fields", value: "id,collectionId,name,icon,voltage_in,voltage_out,watt")
|
||||
]
|
||||
let perPage = 200
|
||||
var page = 1
|
||||
var allRecords: [PocketBaseRecord] = []
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw URLError(.badURL)
|
||||
while true {
|
||||
var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("api/collections/components/records"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "filter", value: "(type='load')"),
|
||||
URLQueryItem(name: "sort", value: "+name"),
|
||||
URLQueryItem(name: "fields", value: "id,collectionId,name,icon,voltage_in,voltage_out,watt"),
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "perPage", value: "\(perPage)")
|
||||
]
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
|
||||
allRecords.append(contentsOf: decoded.items)
|
||||
|
||||
let isLastPage: Bool
|
||||
if let totalPages = decoded.totalPages, totalPages > 0 {
|
||||
isLastPage = page >= totalPages
|
||||
} else {
|
||||
isLastPage = decoded.items.count < perPage
|
||||
}
|
||||
|
||||
if isLastPage {
|
||||
break
|
||||
}
|
||||
|
||||
page += 1
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(PocketBaseResponse.self, from: data)
|
||||
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: decoded.items.map(\.id))
|
||||
let mappedItems = decoded.items.map { record in
|
||||
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
|
||||
let mappedItems = allRecords.map { record in
|
||||
ComponentLibraryItem(
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -264,6 +290,9 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private struct PocketBaseResponse: Decodable {
|
||||
let page: Int?
|
||||
let perPage: Int?
|
||||
let totalPages: Int?
|
||||
let items: [PocketBaseRecord]
|
||||
}
|
||||
|
||||
|
||||
121
Cable/ComponentsOnboardingView.swift
Normal file
@@ -0,0 +1,121 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ComponentsOnboardingView: View {
|
||||
@State private var carouselStep = 0
|
||||
let onCreate: () -> Void
|
||||
let onBrowse: () -> Void
|
||||
|
||||
private let imageNames = [
|
||||
"router-onboarding",
|
||||
"coffee-onboarding",
|
||||
"charger-onboarding"
|
||||
]
|
||||
|
||||
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
|
||||
private let animationDuration = 0.8
|
||||
|
||||
private var loopingImages: [String] {
|
||||
guard let first = imageNames.first else { return [] }
|
||||
return imageNames + [first]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer(minLength: 32)
|
||||
|
||||
OnboardingCarouselView(images: loopingImages, step: carouselStep)
|
||||
.frame(minHeight: 80, maxHeight: 240)
|
||||
.padding(.horizontal, 0)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Add your first component")
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(minHeight: 72)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button(action: createComponent) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Create Component")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.accessibilityIdentifier("create-component-button")
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onBrowse) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "books.vertical")
|
||||
.font(.system(size: 16))
|
||||
Text("Browse Library")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.blue.opacity(0.12))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear(perform: resetState)
|
||||
.onReceive(timer) { _ in advanceCarousel() }
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
carouselStep = 0
|
||||
}
|
||||
|
||||
private func createComponent() {
|
||||
onCreate()
|
||||
}
|
||||
|
||||
private func advanceCarousel() {
|
||||
guard imageNames.count > 1 else { return }
|
||||
let next = carouselStep + 1
|
||||
|
||||
withAnimation(.easeInOut(duration: animationDuration)) {
|
||||
carouselStep = next
|
||||
}
|
||||
|
||||
if next == imageNames.count {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
|
||||
withAnimation(.none) {
|
||||
carouselStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ComponentsOnboardingView(onCreate: {}, onBrowse: {})
|
||||
}
|
||||
@@ -16,6 +16,38 @@ struct SystemsView: View {
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSettings = false
|
||||
|
||||
private let systemColorOptions = [
|
||||
"blue", "green", "orange", "red", "purple", "yellow",
|
||||
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
|
||||
]
|
||||
private let defaultSystemIconName = "building.2"
|
||||
private let systemIconMappings: [(keywords: [String], icon: String)] = [
|
||||
(["rv", "van", "camper", "motorhome", "coach"], "bus"),
|
||||
(["truck", "trailer", "rig"], "truck.box"),
|
||||
(["boat", "marine", "yacht", "sail"], "sailboat"),
|
||||
(["plane", "air", "flight"], "airplane"),
|
||||
(["ferry", "ship"], "ferry"),
|
||||
(["house", "home", "cabin", "cottage", "lodge"], "house"),
|
||||
(["building", "office", "warehouse", "factory", "facility"], "building"),
|
||||
(["camp", "tent", "outdoor"], "tent"),
|
||||
(["solar", "sun"], "sun.max"),
|
||||
(["battery", "storage"], "battery.100"),
|
||||
(["server", "data", "network", "rack"], "server.rack"),
|
||||
(["computer", "electronics", "lab", "tech"], "cpu"),
|
||||
(["gear", "mechanic", "machine", "workshop"], "gear"),
|
||||
(["tool", "maintenance", "repair", "shop"], "wrench.adjustable"),
|
||||
(["hammer", "carpentry"], "hammer"),
|
||||
(["light", "lighting", "lamp"], "lightbulb"),
|
||||
(["bolt", "power", "electric"], "bolt"),
|
||||
(["plug"], "powerplug"),
|
||||
(["engine", "generator", "motor"], "engine.combustion"),
|
||||
(["fuel", "diesel", "gas"], "fuelpump"),
|
||||
(["water", "pump", "tank"], "drop"),
|
||||
(["heat", "heater", "furnace"], "flame"),
|
||||
(["cold", "freeze", "cool"], "snowflake"),
|
||||
(["climate", "hvac", "temperature"], "thermometer")
|
||||
]
|
||||
|
||||
private struct SystemNavigationTarget: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let system: ElectricalSystem
|
||||
@@ -115,93 +147,8 @@ struct SystemsView: View {
|
||||
}
|
||||
|
||||
private var systemsEmptyState: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 24) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "bolt.circle")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Welcome to Cable by VoltPlan")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("We'll create your first system and component so you can jump straight into the calculator.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button(action: {
|
||||
startComponentOnboarding()
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Create Component")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: {
|
||||
showingComponentLibrary = true
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "square.grid.3x3")
|
||||
.font(.system(size: 16))
|
||||
Text("Browse VoltPlan Library")
|
||||
.fontWeight(.medium)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.system(size: 16))
|
||||
Text("Important Safety Notice")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
Text("This app provides estimates for educational purposes only. Always consult qualified electricians and follow local electrical codes for actual installations. Electrical work can be dangerous and should only be performed by licensed professionals.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
SystemsOnboardingView { name in
|
||||
createOnboardingSystem(named: name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +157,19 @@ struct SystemsView: View {
|
||||
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
|
||||
}
|
||||
|
||||
private func createNewSystem(named name: String) {
|
||||
let system = makeSystem(preferredName: name)
|
||||
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
|
||||
}
|
||||
|
||||
private func createOnboardingSystem(named name: String) {
|
||||
let system = makeSystem(
|
||||
preferredName: name,
|
||||
colorName: randomSystemColorName()
|
||||
)
|
||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
|
||||
}
|
||||
|
||||
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
|
||||
let target = SystemNavigationTarget(
|
||||
system: system,
|
||||
@@ -229,58 +189,39 @@ struct SystemsView: View {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func makeSystem() -> ElectricalSystem {
|
||||
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
|
||||
let existingNames = Set(systems.map { $0.name })
|
||||
var systemName = "New System"
|
||||
var counter = 1
|
||||
|
||||
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
|
||||
systemName = "New System \(counter)"
|
||||
}
|
||||
|
||||
|
||||
let resolvedColorName = colorName ?? "blue"
|
||||
let resolvedIconName = iconName ?? systemIconName(for: systemName)
|
||||
|
||||
let newSystem = ElectricalSystem(
|
||||
name: systemName,
|
||||
location: "",
|
||||
iconName: "building.2",
|
||||
colorName: "blue"
|
||||
iconName: resolvedIconName,
|
||||
colorName: resolvedColorName
|
||||
)
|
||||
modelContext.insert(newSystem)
|
||||
return newSystem
|
||||
}
|
||||
|
||||
private func startComponentOnboarding() {
|
||||
let system = makeSystem()
|
||||
let load = createNewLoad(in: system)
|
||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
|
||||
}
|
||||
|
||||
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
||||
let system = makeSystem()
|
||||
let load = createLoad(from: item, in: system)
|
||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
|
||||
}
|
||||
|
||||
private func createNewLoad(in system: ElectricalSystem) -> SavedLoad {
|
||||
let newLoad = SavedLoad(
|
||||
name: "New Load",
|
||||
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
|
||||
)
|
||||
modelContext.insert(newLoad)
|
||||
return newLoad
|
||||
}
|
||||
|
||||
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
||||
let baseName = item.name.isEmpty ? "Library Load" : item.name
|
||||
let baseName = item.name.isEmpty ? String(localized: "default.load.library", comment: "Default name when importing a library load") : item.name
|
||||
let loadName = uniqueLoadName(for: system, startingWith: baseName)
|
||||
let voltage = item.displayVoltage ?? 12.0
|
||||
|
||||
@@ -369,7 +310,9 @@ struct SystemsView: View {
|
||||
|
||||
private func componentSummary(for system: ElectricalSystem) -> String {
|
||||
let systemLoads = loads(for: system)
|
||||
guard !systemLoads.isEmpty else { return "No components yet" }
|
||||
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 }
|
||||
@@ -381,9 +324,31 @@ struct SystemsView: View {
|
||||
formattedPower = String(format: "%.0fW", totalPower)
|
||||
}
|
||||
|
||||
return "\(count) component\(count == 1 ? "" : "s") • \(formattedPower) total"
|
||||
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 colorForName(_ colorName: String) -> Color {
|
||||
switch colorName {
|
||||
case "blue": return .blue
|
||||
@@ -431,11 +396,11 @@ struct LoadsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
librarySection
|
||||
|
||||
if savedLoads.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
librarySection
|
||||
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
NavigationLink(destination: CalculatorView(savedLoad: load)) {
|
||||
@@ -648,54 +613,10 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.blue.opacity(0.1))
|
||||
.frame(width: 72, height: 72)
|
||||
|
||||
Image(systemName: "bolt.circle")
|
||||
.font(.system(size: 34))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text("No Components Yet")
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Add a component to this system to see cable and fuse recommendations.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
createNewLoad()
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Create Component")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
ComponentsOnboardingView(
|
||||
onCreate: { createNewLoad() },
|
||||
onBrowse: { showingComponentLibrary = true }
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteLoads(offsets: IndexSet) {
|
||||
@@ -707,7 +628,8 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
private func createNewLoad() {
|
||||
let loadName = uniqueLoadName(startingWith: "New Load")
|
||||
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
|
||||
let loadName = uniqueLoadName(startingWith: defaultName)
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: 12.0,
|
||||
@@ -988,6 +910,22 @@ private struct SystemBillOfMaterialsView: View {
|
||||
let destinationURL = destinationURL(for: item.destination, load: load)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
let accessibilityLabel: String = {
|
||||
if isCompleted {
|
||||
let format = NSLocalizedString(
|
||||
"bom.accessibility.mark.incomplete",
|
||||
comment: "Accessibility label instructing VoiceOver to mark an item incomplete"
|
||||
)
|
||||
return String.localizedStringWithFormat(format, item.title)
|
||||
} else {
|
||||
let format = NSLocalizedString(
|
||||
"bom.accessibility.mark.complete",
|
||||
comment: "Accessibility label instructing VoiceOver to mark an item complete"
|
||||
)
|
||||
return String.localizedStringWithFormat(format, item.title)
|
||||
}
|
||||
}()
|
||||
|
||||
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(isCompleted ? .accentColor : .secondary)
|
||||
.imageScale(.large)
|
||||
@@ -995,7 +933,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
setCompletion(!isCompleted, for: load, item: item)
|
||||
suppressRowTapForID = item.id
|
||||
}
|
||||
.accessibilityLabel(isCompleted ? "Mark \(item.title) incomplete" : "Mark \(item.title) complete")
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
@@ -1004,7 +942,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
|
||||
|
||||
if item.isPrimaryComponent {
|
||||
Text("Component")
|
||||
Text(String(localized: "component.fallback.name", comment: "Tag label marking an item as the component itself"))
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
@@ -1056,7 +994,16 @@ private struct SystemBillOfMaterialsView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle("BOM – \(systemName)")
|
||||
.navigationTitle(
|
||||
String(
|
||||
format: NSLocalizedString(
|
||||
"bom.navigation.title.system",
|
||||
comment: "Navigation title for the bill of materials view"
|
||||
),
|
||||
locale: Locale.current,
|
||||
systemName
|
||||
)
|
||||
)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
@@ -1080,7 +1027,8 @@ private struct SystemBillOfMaterialsView: View {
|
||||
|
||||
private func sectionHeader(for load: SavedLoad) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(load.name.isEmpty ? "Component" : load.name)
|
||||
let fallbackTitle = String(localized: "component.fallback.name", comment: "Fallback title for a component that lacks a name")
|
||||
Text(load.name.isEmpty ? fallbackTitle : load.name)
|
||||
.font(.headline)
|
||||
Text(dateFormatter.string(from: load.timestamp))
|
||||
.font(.caption)
|
||||
@@ -1099,13 +1047,15 @@ private struct SystemBillOfMaterialsView: View {
|
||||
|
||||
let crossSectionLabel: String
|
||||
let gaugeQuery: String
|
||||
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is not yet determined")
|
||||
|
||||
if unitSystem == .imperial {
|
||||
let awg = awgFromCrossSection(load.crossSection)
|
||||
if awg > 0 {
|
||||
crossSectionLabel = String(format: "AWG %.0f", awg)
|
||||
gaugeQuery = String(format: "AWG %.0f", awg)
|
||||
} else {
|
||||
crossSectionLabel = "Size TBD"
|
||||
crossSectionLabel = unknownSizeLabel
|
||||
gaugeQuery = "battery cable"
|
||||
}
|
||||
} else {
|
||||
@@ -1113,7 +1063,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
|
||||
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
|
||||
} else {
|
||||
crossSectionLabel = "Size TBD"
|
||||
crossSectionLabel = unknownSizeLabel
|
||||
gaugeQuery = "battery cable"
|
||||
}
|
||||
}
|
||||
@@ -1124,9 +1074,17 @@ private struct SystemBillOfMaterialsView: View {
|
||||
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
|
||||
|
||||
let fuseRating = recommendedFuse(for: load)
|
||||
let fuseDetail = "Inline holder and \(fuseRating)A fuse"
|
||||
let fuseDetailFormat = NSLocalizedString(
|
||||
"bom.fuse.detail",
|
||||
comment: "Description for the fuse item in the BOM list"
|
||||
)
|
||||
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
|
||||
|
||||
let cableShoesDetail = "Ring or spade terminals sized for \(crossSectionLabel.lowercased()) wiring"
|
||||
let cableShoesDetailFormat = NSLocalizedString(
|
||||
"bom.terminals.detail",
|
||||
comment: "Description for the cable terminals item in the BOM list"
|
||||
)
|
||||
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
|
||||
|
||||
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let deviceQuery = load.name.isEmpty
|
||||
@@ -1142,7 +1100,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "component"),
|
||||
logicalID: "component",
|
||||
title: load.name.isEmpty ? "Component" : load.name,
|
||||
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
|
||||
@@ -1151,7 +1109,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "cable-red"),
|
||||
logicalID: "cable-red",
|
||||
title: "Power Cable (Red)",
|
||||
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
@@ -1160,7 +1118,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "cable-black"),
|
||||
logicalID: "cable-black",
|
||||
title: "Power Cable (Black)",
|
||||
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
@@ -1169,7 +1127,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "fuse"),
|
||||
logicalID: "fuse",
|
||||
title: "Fuse & Holder",
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
@@ -1178,7 +1136,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "terminals"),
|
||||
logicalID: "terminals",
|
||||
title: "Cable Shoes / Terminals",
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
@@ -1194,7 +1152,7 @@ private struct SystemBillOfMaterialsView: View {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
let countryCode = load.affiliateCountryCode ?? Locale.current.regionCode
|
||||
let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
|
||||
}
|
||||
}
|
||||
@@ -1256,11 +1214,10 @@ private struct SystemBillOfMaterialsView: View {
|
||||
}
|
||||
|
||||
private var footerMessage: String {
|
||||
if loads.contains(where: { $0.affiliateURLString != nil }) {
|
||||
return "Affiliate links may support VoltPlan; other rows open Amazon searches for sourcing guidance."
|
||||
} else {
|
||||
return "Amazon searches are provided to help you source comparable parts for this system."
|
||||
}
|
||||
NSLocalizedString(
|
||||
"affiliate.disclaimer",
|
||||
comment: "Footer note reminding users that affiliate purchases may support the app"
|
||||
)
|
||||
}
|
||||
|
||||
private var dateFormatter: DateFormatter {
|
||||
|
||||
@@ -23,9 +23,9 @@ struct LoadEditorView: View {
|
||||
|
||||
var body: some View {
|
||||
ItemEditorView(
|
||||
title: "Edit Load",
|
||||
nameFieldLabel: "Load name",
|
||||
previewSubtitle: "Preview",
|
||||
title: String(localized: "editor.load.title", comment: "Title for the load editor"),
|
||||
nameFieldLabel: String(localized: "editor.load.name_field", comment: "Label for the load name text field"),
|
||||
previewSubtitle: String(localized: "editor.load.preview", comment: "Placeholder subtitle in the load editor preview"),
|
||||
icons: loadIcons,
|
||||
name: $loadName,
|
||||
iconName: $iconName,
|
||||
@@ -36,10 +36,10 @@ struct LoadEditorView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@State var name = "My Load"
|
||||
@State var icon = "lightbulb"
|
||||
@State var color = "blue"
|
||||
@State var remoteIcon: String? = "https://example.com/icon.png"
|
||||
@Previewable @State var name = "My Load"
|
||||
@Previewable @State var icon = "lightbulb"
|
||||
@Previewable @State var color = "blue"
|
||||
@Previewable @State var remoteIcon: String? = "https://example.com/icon.png"
|
||||
|
||||
return LoadEditorView(loadName: $name, iconName: $icon, colorName: $color, remoteIconURLString: $remoteIcon)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ struct LoadIconView: View {
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.onChange(of: remoteIconURLString) { _ in
|
||||
.onChange(of: remoteIconURLString) { oldValue, newValue in
|
||||
guard oldValue != newValue else { return }
|
||||
cachedImage = nil
|
||||
hasAttemptedLoad = false
|
||||
}
|
||||
|
||||
32
Cable/OnboardingCarouselView.swift
Normal file
@@ -0,0 +1,32 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnboardingCarouselView: View {
|
||||
let images: [String]
|
||||
let step: Int
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let height = geometry.size.height
|
||||
|
||||
ZStack {
|
||||
if images.isEmpty {
|
||||
Image(systemName: "photo")
|
||||
.font(.largeTitle)
|
||||
.foregroundStyle(Color.secondary)
|
||||
} else {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(images.enumerated()), id: \.offset) { _, name in
|
||||
Image(name)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
}
|
||||
.offset(x: -CGFloat(step) * width)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,17 +32,21 @@ struct SystemEditorView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let editorTitle = String(localized: "editor.system.title", comment: "Title for the system editor")
|
||||
let namePlaceholder = String(localized: "editor.system.name_field", comment: "Label for the system name text field")
|
||||
let locationPlaceholder = String(localized: "editor.system.location.optional", comment: "Placeholder text shown when no location is specified")
|
||||
|
||||
ItemEditorView(
|
||||
title: "Edit System",
|
||||
nameFieldLabel: "System name",
|
||||
previewSubtitle: tempLocation.isEmpty ? "Location (optional)" : tempLocation,
|
||||
title: editorTitle,
|
||||
nameFieldLabel: namePlaceholder,
|
||||
previewSubtitle: tempLocation.isEmpty ? locationPlaceholder : tempLocation,
|
||||
icons: systemIcons,
|
||||
name: $systemName,
|
||||
iconName: $iconName,
|
||||
colorName: $colorName,
|
||||
additionalFields: {
|
||||
AnyView(
|
||||
TextField("Location (optional)", text: $tempLocation)
|
||||
TextField(locationPlaceholder, text: $tempLocation)
|
||||
.autocapitalization(.words)
|
||||
.onChange(of: tempLocation) { _, newValue in
|
||||
location = newValue
|
||||
@@ -57,10 +61,10 @@ struct SystemEditorView: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
@State var name = "My System"
|
||||
@State var location = "Main Building"
|
||||
@State var icon = "building.2"
|
||||
@State var color = "blue"
|
||||
@Previewable @State var name = "My System"
|
||||
@Previewable @State var location = "Main Building"
|
||||
@Previewable @State var icon = "building.2"
|
||||
@Previewable @State var color = "blue"
|
||||
|
||||
return SystemEditorView(systemName: $name, location: $location, iconName: $icon, colorName: $color)
|
||||
}
|
||||
|
||||
129
Cable/SystemsOnboardingView.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SystemsOnboardingView: View {
|
||||
@State private var systemName: String = String(localized: "default.system.name", comment: "Default placeholder name for a system")
|
||||
@State private var carouselStep = 0
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
let onCreate: (String) -> Void
|
||||
|
||||
private let imageNames = [
|
||||
"van-onboarding",
|
||||
"cabin-onboarding",
|
||||
"boat-onboarding"
|
||||
]
|
||||
|
||||
private let timer = Timer.publish(every: 8, on: .main, in: .common).autoconnect()
|
||||
private let animationDuration = 0.8
|
||||
|
||||
private var loopingImages: [String] {
|
||||
guard let first = imageNames.first else { return [] }
|
||||
return imageNames + [first]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack() {
|
||||
Spacer(minLength: 32)
|
||||
|
||||
OnboardingCarouselView(images: loopingImages, step: carouselStep)
|
||||
.frame(minHeight: 80, maxHeight: 240)
|
||||
.padding(.horizontal, 0)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Create your first system")
|
||||
.font(.title2.weight(.semibold))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place.")
|
||||
.font(.body)
|
||||
.foregroundStyle(Color.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(minHeight: 96)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
Spacer()
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(isFieldFocused ? Color.accentColor : Color.accentColor.opacity(0.6))
|
||||
|
||||
TextField("System Name", text: $systemName)
|
||||
.textInputAutocapitalization(.words)
|
||||
.disableAutocorrection(true)
|
||||
.focused($isFieldFocused)
|
||||
.submitLabel(.done)
|
||||
.onSubmit(createSystem)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(Color(.secondarySystemBackground))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(isFieldFocused ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.08), radius: 12, x: 0, y: 6)
|
||||
.animation(.easeInOut(duration: 0.2), value: isFieldFocused)
|
||||
|
||||
Button(action: createSystem) {
|
||||
HStack(spacing:8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
Text("Create System")
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(12)
|
||||
}
|
||||
.accessibilityIdentifier("create-system-button")
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
}
|
||||
.padding(.bottom, 32)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.onAppear(perform: resetState)
|
||||
.onReceive(timer) { _ in advanceCarousel() }
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
systemName = String(localized: "default.system.name", comment: "Default placeholder name for a system")
|
||||
carouselStep = 0
|
||||
}
|
||||
|
||||
private func createSystem() {
|
||||
isFieldFocused = false
|
||||
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onCreate(trimmed)
|
||||
}
|
||||
|
||||
private func advanceCarousel() {
|
||||
guard imageNames.count > 1 else { return }
|
||||
let next = carouselStep + 1
|
||||
|
||||
withAnimation(.easeInOut(duration: animationDuration)) {
|
||||
carouselStep = next
|
||||
}
|
||||
|
||||
if next == imageNames.count {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
|
||||
withAnimation(.none) {
|
||||
carouselStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SystemsOnboardingView { _ in }
|
||||
}
|
||||
@@ -14,9 +14,9 @@ enum UnitSystem: String, CaseIterable {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .metric:
|
||||
return "Metric (mm², m)"
|
||||
return String(localized: "units.metric.display", comment: "Display name for the metric unit system")
|
||||
case .imperial:
|
||||
return "Imperial (AWG, ft)"
|
||||
return String(localized: "units.imperial.display", comment: "Display name for the imperial unit system")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,4 +51,4 @@ class UnitSystemSettings: ObservableObject {
|
||||
let savedSystem = UserDefaults.standard.string(forKey: "unitSystem") ?? UnitSystem.metric.rawValue
|
||||
self.unitSystem = UnitSystem(rawValue: savedSystem) ?? .metric
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
105
Cable/de.lproj/Localizable.strings
Normal file
@@ -0,0 +1,105 @@
|
||||
// Keys
|
||||
"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.";
|
||||
"bom.accessibility.mark.complete" = "Markiere %@ als erledigt";
|
||||
"bom.accessibility.mark.incomplete" = "Markiere %@ als unerledigt";
|
||||
"bom.fuse.detail" = "Inline-Halter und %dA-Sicherung";
|
||||
"bom.item.cable.black" = "Stromkabel (schwarz)";
|
||||
"bom.item.cable.red" = "Stromkabel (rot)";
|
||||
"bom.item.fuse" = "Sicherung & Halter";
|
||||
"bom.item.terminals" = "Kabelschuhe / Klemmen";
|
||||
"bom.navigation.title" = "Stückliste";
|
||||
"bom.navigation.title.system" = "Stückliste – %@";
|
||||
"bom.size.unknown" = "Größe offen";
|
||||
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
||||
"component.fallback.name" = "Komponente";
|
||||
"default.load.library" = "Bibliothekslast";
|
||||
"default.load.name" = "Mein Verbraucher";
|
||||
"default.load.unnamed" = "Unbenannter Verbraucher";
|
||||
"default.load.new" = "Neuer Verbraucher";
|
||||
"default.system.name" = "Mein System";
|
||||
"default.system.new" = "Neues System";
|
||||
"editor.load.name_field" = "Name des Verbrauchers";
|
||||
"editor.load.preview" = "Vorschau";
|
||||
"editor.load.title" = "Verbraucher bearbeiten";
|
||||
"editor.system.location.optional" = "Standort (optional)";
|
||||
"editor.system.name_field" = "Name des Systems";
|
||||
"editor.system.title" = "System bearbeiten";
|
||||
"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";
|
||||
"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:";
|
||||
"• Always consult qualified electricians for actual installations" = "• Ziehe für tatsächliche Installationen stets qualifizierte Elektriker hinzu";
|
||||
"• Follow all local electrical codes and regulations" = "• Beachte alle örtlichen Vorschriften und Normen";
|
||||
"• Electrical work should only be performed by licensed professionals" = "• Elektroarbeiten sollten nur von zertifizierten Fachkräften ausgeführt werden";
|
||||
"• These calculations may not account for all environmental factors" = "• Diese Berechnungen berücksichtigen möglicherweise nicht alle Umgebungsfaktoren";
|
||||
"• The app developers assume no liability for electrical installations" = "• Die App-Entwickler übernehmen keine Haftung für elektrische Installationen";
|
||||
"Enter length in %@" = "Gib die Länge in %@ ein";
|
||||
"Enter voltage in volts (V)" = "Gib die Spannung in Volt (V) ein";
|
||||
"Enter current in amperes (A)" = "Gib den Strom in Ampere (A) ein";
|
||||
"Enter power in watts (W)" = "Gib die Leistung in Watt (W) ein";
|
||||
"Edit Length" = "Länge bearbeiten";
|
||||
"Edit Voltage" = "Spannung bearbeiten";
|
||||
"Edit Current" = "Strom bearbeiten";
|
||||
"Edit Power" = "Leistung bearbeiten";
|
||||
"Preview" = "Vorschau";
|
||||
"Details" = "Details";
|
||||
"Icon" = "Symbol";
|
||||
"Color" = "Farbe";
|
||||
"VoltPlan Library" = "VoltPlan-Bibliothek";
|
||||
"New Load" = "Neuer Verbraucher";
|
||||
22
Cable/de.lproj/Localizable.stringsdict
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>system.list.component.summary</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@component_count@ • %@</string>
|
||||
<key>component_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d Komponente</string>
|
||||
<key>other</key>
|
||||
<string>%d Komponenten</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
104
Cable/es.lproj/Localizable.strings
Normal file
@@ -0,0 +1,104 @@
|
||||
// Keys
|
||||
"affiliate.button.review_parts" = "Revisar componentes";
|
||||
"affiliate.description.with_link" = "Al tocar verás una lista completa de materiales antes de abrir el enlace de afiliado. Las compras pueden apoyar a VoltPlan.";
|
||||
"affiliate.description.without_link" = "Al tocar verás una lista completa de materiales con búsquedas de compra para ayudarte a conseguir piezas.";
|
||||
"affiliate.disclaimer" = "Las compras a través de enlaces de afiliados pueden apoyar a VoltPlan.";
|
||||
"bom.accessibility.mark.complete" = "Marcar %@ como completado";
|
||||
"bom.accessibility.mark.incomplete" = "Marcar %@ como pendiente";
|
||||
"bom.fuse.detail" = "Portafusibles en línea y fusible de %d A";
|
||||
"bom.item.cable.black" = "Cable de alimentación (negro)";
|
||||
"bom.item.cable.red" = "Cable de alimentación (rojo)";
|
||||
"bom.item.fuse" = "Fusible y portafusibles";
|
||||
"bom.item.terminals" = "Terminales / zapatas";
|
||||
"bom.navigation.title" = "Lista de materiales";
|
||||
"bom.navigation.title.system" = "Lista de materiales – %@";
|
||||
"bom.size.unknown" = "Tamaño por definir";
|
||||
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
||||
"component.fallback.name" = "Componente";
|
||||
"default.load.library" = "Carga de la biblioteca";
|
||||
"default.load.name" = "Mi carga";
|
||||
"default.load.unnamed" = "Carga sin nombre";
|
||||
"default.load.new" = "Carga nueva";
|
||||
"default.system.name" = "Mi sistema";
|
||||
"default.system.new" = "Sistema nuevo";
|
||||
"editor.load.name_field" = "Nombre de la carga";
|
||||
"editor.load.preview" = "Vista previa";
|
||||
"editor.load.title" = "Editar carga";
|
||||
"editor.system.location.optional" = "Ubicación (opcional)";
|
||||
"editor.system.name_field" = "Nombre del sistema";
|
||||
"editor.system.title" = "Editar sistema";
|
||||
"slider.button.ampere" = "Amperios";
|
||||
"slider.button.watt" = "Vatios";
|
||||
"slider.current.title" = "Corriente";
|
||||
"slider.length.title" = "Longitud del cable (%@)";
|
||||
"slider.power.title" = "Potencia";
|
||||
"slider.voltage.title" = "Voltaje";
|
||||
"system.list.no.components" = "Aún no hay componentes";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Métrico (mm², m)";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Sistemas";
|
||||
"System" = "Sistema";
|
||||
"System View" = "Vista del sistema";
|
||||
"System Name" = "Nombre del sistema";
|
||||
"Create System" = "Crear sistema";
|
||||
"Create your first system" = "Crea tu primer sistema";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ponle un nombre a tu sistema para que **Cable by VoltPlan** organice cargas, cableado y recomendaciones en un solo lugar.";
|
||||
"Add your first component" = "Añade tu primer componente";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Da vida a tu sistema con componentes y deja que **Cable by VoltPlan** se encargue de recomendar cables y fusibles.";
|
||||
"Create Component" = "Crear componente";
|
||||
"Browse Library" = "Explorar biblioteca";
|
||||
"Browse" = "Explorar";
|
||||
"Browse electrical components from VoltPlan" = "Explora los componentes eléctricos de VoltPlan";
|
||||
"Component Library" = "Biblioteca de componentes";
|
||||
"Details coming soon" = "Detalles próximamente";
|
||||
"Components" = "Componentes";
|
||||
"FUSE" = "FUSIBLE";
|
||||
"WIRE" = "CABLE";
|
||||
"Current" = "Corriente";
|
||||
"Power" = "Potencia";
|
||||
"Voltage" = "Voltaje";
|
||||
"Length" = "Longitud";
|
||||
"Length:" = "Longitud:";
|
||||
"Wire Cross-Section:" = "Sección del cable:";
|
||||
"Current Units" = "Unidades actuales";
|
||||
"Unit System" = "Sistema de unidades";
|
||||
"Units" = "Unidades";
|
||||
"Settings" = "Ajustes";
|
||||
"Close" = "Cerrar";
|
||||
"Cancel" = "Cancelar";
|
||||
"Save" = "Guardar";
|
||||
"Retry" = "Reintentar";
|
||||
"Loading components" = "Cargando componentes";
|
||||
"Unable to load components" = "No se pudieron cargar los componentes";
|
||||
"No components available" = "No hay componentes disponibles";
|
||||
"No matches" = "Sin coincidencias";
|
||||
"Check back soon for new loads from VoltPlan." = "Vuelve pronto para encontrar nuevas cargas de VoltPlan.";
|
||||
"Try searching for a different name." = "Prueba a buscar otro nombre.";
|
||||
"Search components" = "Buscar componentes";
|
||||
"No loads saved in this system yet." = "Todavía no hay cargas guardadas en este sistema.";
|
||||
"Coming soon - manage your electrical systems and panels here." = "Próximamente: gestiona aquí tus sistemas y paneles eléctricos.";
|
||||
"Load Library" = "Biblioteca de cargas";
|
||||
"Safety Disclaimer" = "Aviso de seguridad";
|
||||
"This application provides electrical calculations for educational and estimation purposes only." = "Esta aplicación proporciona cálculos eléctricos únicamente con fines educativos y de estimación.";
|
||||
"Important:" = "Importante:";
|
||||
"• Always consult qualified electricians for actual installations" = "• Consulta siempre a electricistas calificados para las instalaciones reales";
|
||||
"• Follow all local electrical codes and regulations" = "• Cumple todas las normativas y códigos eléctricos locales";
|
||||
"• Electrical work should only be performed by licensed professionals" = "• Los trabajos eléctricos solo deben realizarlos profesionales autorizados";
|
||||
"• These calculations may not account for all environmental factors" = "• Estos cálculos pueden no tener en cuenta todos los factores ambientales";
|
||||
"• The app developers assume no liability for electrical installations" = "• Los desarrolladores de la app no asumen responsabilidad por las instalaciones eléctricas";
|
||||
"Enter length in %@" = "Introduce la longitud en %@";
|
||||
"Enter voltage in volts (V)" = "Introduce el voltaje en voltios (V)";
|
||||
"Enter current in amperes (A)" = "Introduce la corriente en amperios (A)";
|
||||
"Enter power in watts (W)" = "Introduce la potencia en vatios (W)";
|
||||
"Edit Length" = "Editar longitud";
|
||||
"Edit Voltage" = "Editar voltaje";
|
||||
"Edit Current" = "Editar corriente";
|
||||
"Edit Power" = "Editar potencia";
|
||||
"Preview" = "Vista previa";
|
||||
"Details" = "Detalles";
|
||||
"Icon" = "Icono";
|
||||
"Color" = "Color";
|
||||
"VoltPlan Library" = "Biblioteca de VoltPlan";
|
||||
"New Load" = "Carga nueva";
|
||||
22
Cable/es.lproj/Localizable.stringsdict
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>system.list.component.summary</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@component_count@ • %@</string>
|
||||
<key>component_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d componente</string>
|
||||
<key>other</key>
|
||||
<string>%d componentes</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
104
Cable/fr.lproj/Localizable.strings
Normal file
@@ -0,0 +1,104 @@
|
||||
// Keys
|
||||
"affiliate.button.review_parts" = "Examiner les composants";
|
||||
"affiliate.description.with_link" = "En appuyant, vous verrez une liste complète de matériel avant d'ouvrir le lien d'affiliation. Les achats peuvent soutenir VoltPlan.";
|
||||
"affiliate.description.without_link" = "En appuyant, vous verrez une liste complète de matériel avec des recherches d'achat pour vous aider à trouver les pièces.";
|
||||
"affiliate.disclaimer" = "Les achats effectués via des liens d'affiliation peuvent soutenir VoltPlan.";
|
||||
"bom.accessibility.mark.complete" = "Marquer %@ comme terminé";
|
||||
"bom.accessibility.mark.incomplete" = "Marquer %@ comme à faire";
|
||||
"bom.fuse.detail" = "Porte-fusible en ligne et fusible de %d A";
|
||||
"bom.item.cable.black" = "Câble d'alimentation (noir)";
|
||||
"bom.item.cable.red" = "Câble d'alimentation (rouge)";
|
||||
"bom.item.fuse" = "Fusible et porte-fusible";
|
||||
"bom.item.terminals" = "Cosses / bornes";
|
||||
"bom.navigation.title" = "Liste de matériel";
|
||||
"bom.navigation.title.system" = "Liste de matériel – %@";
|
||||
"bom.size.unknown" = "Taille à déterminer";
|
||||
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
||||
"component.fallback.name" = "Composant";
|
||||
"default.load.library" = "Charge de la bibliothèque";
|
||||
"default.load.name" = "Ma charge";
|
||||
"default.load.unnamed" = "Charge sans nom";
|
||||
"default.load.new" = "Nouvelle charge";
|
||||
"default.system.name" = "Mon système";
|
||||
"default.system.new" = "Nouveau système";
|
||||
"editor.load.name_field" = "Nom de la charge";
|
||||
"editor.load.preview" = "Aperçu";
|
||||
"editor.load.title" = "Modifier la charge";
|
||||
"editor.system.location.optional" = "Emplacement (optionnel)";
|
||||
"editor.system.name_field" = "Nom du système";
|
||||
"editor.system.title" = "Modifier le système";
|
||||
"slider.button.ampere" = "Ampères";
|
||||
"slider.button.watt" = "Watts";
|
||||
"slider.current.title" = "Courant";
|
||||
"slider.length.title" = "Longueur du câble (%@)";
|
||||
"slider.power.title" = "Puissance";
|
||||
"slider.voltage.title" = "Tension";
|
||||
"system.list.no.components" = "Aucun composant pour l'instant";
|
||||
"units.imperial.display" = "Impérial (AWG, ft)";
|
||||
"units.metric.display" = "Métrique (mm², m)";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systèmes";
|
||||
"System" = "Système";
|
||||
"System View" = "Vue système";
|
||||
"System Name" = "Nom du système";
|
||||
"Create System" = "Créer un système";
|
||||
"Create your first system" = "Créez votre premier système";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Donnez un nom à votre installation pour que **Cable by VoltPlan** organise charges, câblage et recommandations au même endroit.";
|
||||
"Add your first component" = "Ajoutez votre premier composant";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Donnez vie à votre système avec des composants et laissez **Cable by VoltPlan** recommander câbles et fusibles.";
|
||||
"Create Component" = "Créer un composant";
|
||||
"Browse Library" = "Parcourir la bibliothèque";
|
||||
"Browse" = "Parcourir";
|
||||
"Browse electrical components from VoltPlan" = "Parcourez les composants électriques de VoltPlan";
|
||||
"Component Library" = "Bibliothèque de composants";
|
||||
"Details coming soon" = "Détails à venir";
|
||||
"Components" = "Composants";
|
||||
"FUSE" = "FUSIBLE";
|
||||
"WIRE" = "CÂBLE";
|
||||
"Current" = "Courant";
|
||||
"Power" = "Puissance";
|
||||
"Voltage" = "Tension";
|
||||
"Length" = "Longueur";
|
||||
"Length:" = "Longueur :";
|
||||
"Wire Cross-Section:" = "Section du câble :";
|
||||
"Current Units" = "Unités actuelles";
|
||||
"Unit System" = "Système d'unités";
|
||||
"Units" = "Unités";
|
||||
"Settings" = "Réglages";
|
||||
"Close" = "Fermer";
|
||||
"Cancel" = "Annuler";
|
||||
"Save" = "Enregistrer";
|
||||
"Retry" = "Réessayer";
|
||||
"Loading components" = "Chargement des composants";
|
||||
"Unable to load components" = "Impossible de charger les composants";
|
||||
"No components available" = "Aucun composant disponible";
|
||||
"No matches" = "Aucune correspondance";
|
||||
"Check back soon for new loads from VoltPlan." = "Revenez bientôt pour découvrir de nouvelles charges VoltPlan.";
|
||||
"Try searching for a different name." = "Essayez une autre recherche.";
|
||||
"Search components" = "Rechercher des composants";
|
||||
"No loads saved in this system yet." = "Aucune charge enregistrée pour ce système pour l'instant.";
|
||||
"Coming soon - manage your electrical systems and panels here." = "Bientôt disponible : gérez ici vos systèmes électriques et vos tableaux.";
|
||||
"Load Library" = "Bibliothèque de charges";
|
||||
"Safety Disclaimer" = "Avertissement de sécurité";
|
||||
"This application provides electrical calculations for educational and estimation purposes only." = "Cette application fournit des calculs électriques uniquement à des fins pédagogiques et d'estimation.";
|
||||
"Important:" = "Important :";
|
||||
"• Always consult qualified electricians for actual installations" = "• Faites toujours appel à des électriciens qualifiés pour les installations réelles";
|
||||
"• Follow all local electrical codes and regulations" = "• Respectez toutes les normes et réglementations électriques locales";
|
||||
"• Electrical work should only be performed by licensed professionals" = "• Les travaux électriques doivent être réalisés uniquement par des professionnels certifiés";
|
||||
"• These calculations may not account for all environmental factors" = "• Ces calculs peuvent ne pas prendre en compte tous les facteurs environnementaux";
|
||||
"• The app developers assume no liability for electrical installations" = "• Les développeurs de l'application déclinent toute responsabilité quant aux installations électriques";
|
||||
"Enter length in %@" = "Saisissez la longueur en %@";
|
||||
"Enter voltage in volts (V)" = "Saisissez la tension en volts (V)";
|
||||
"Enter current in amperes (A)" = "Saisissez le courant en ampères (A)";
|
||||
"Enter power in watts (W)" = "Saisissez la puissance en watts (W)";
|
||||
"Edit Length" = "Modifier la longueur";
|
||||
"Edit Voltage" = "Modifier la tension";
|
||||
"Edit Current" = "Modifier le courant";
|
||||
"Edit Power" = "Modifier la puissance";
|
||||
"Preview" = "Aperçu";
|
||||
"Details" = "Détails";
|
||||
"Icon" = "Icône";
|
||||
"Color" = "Couleur";
|
||||
"VoltPlan Library" = "Bibliothèque VoltPlan";
|
||||
"New Load" = "Nouvelle charge";
|
||||
22
Cable/fr.lproj/Localizable.stringsdict
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>system.list.component.summary</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@component_count@ • %@</string>
|
||||
<key>component_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d composant</string>
|
||||
<key>other</key>
|
||||
<string>%d composants</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
104
Cable/nl.lproj/Localizable.strings
Normal file
@@ -0,0 +1,104 @@
|
||||
// Keys
|
||||
"affiliate.button.review_parts" = "Onderdelen bekijken";
|
||||
"affiliate.description.with_link" = "Tik om een volledige materiaallijst te zien voordat de affiliate-link wordt geopend. Aankopen kunnen VoltPlan ondersteunen.";
|
||||
"affiliate.description.without_link" = "Tik om een volledige materiaallijst te zien met aankoopzoekopdrachten die je helpen onderdelen te vinden.";
|
||||
"affiliate.disclaimer" = "Aankopen via affiliate-links kunnen VoltPlan ondersteunen.";
|
||||
"bom.accessibility.mark.complete" = "Markeer %@ als voltooid";
|
||||
"bom.accessibility.mark.incomplete" = "Markeer %@ als niet voltooid";
|
||||
"bom.fuse.detail" = "In-line zekeringhouder met zekering van %d A";
|
||||
"bom.item.cable.black" = "Voedingskabel (zwart)";
|
||||
"bom.item.cable.red" = "Voedingskabel (rood)";
|
||||
"bom.item.fuse" = "Zekering en houder";
|
||||
"bom.item.terminals" = "Kabelschoenen / klemmen";
|
||||
"bom.navigation.title" = "Materiaallijst";
|
||||
"bom.navigation.title.system" = "Materiaallijst – %@";
|
||||
"bom.size.unknown" = "Afmeting nog onbekend";
|
||||
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
||||
"component.fallback.name" = "Component";
|
||||
"default.load.library" = "Bibliotheeklast";
|
||||
"default.load.name" = "Mijn last";
|
||||
"default.load.unnamed" = "Naamloze last";
|
||||
"default.load.new" = "Nieuwe last";
|
||||
"default.system.name" = "Mijn systeem";
|
||||
"default.system.new" = "Nieuw systeem";
|
||||
"editor.load.name_field" = "Naam van de last";
|
||||
"editor.load.preview" = "Voorbeeld";
|
||||
"editor.load.title" = "Last bewerken";
|
||||
"editor.system.location.optional" = "Locatie (optioneel)";
|
||||
"editor.system.name_field" = "Naam van het systeem";
|
||||
"editor.system.title" = "Systeem bewerken";
|
||||
"slider.button.ampere" = "Ampère";
|
||||
"slider.button.watt" = "Watt";
|
||||
"slider.current.title" = "Stroom";
|
||||
"slider.length.title" = "Kabellengte (%@)";
|
||||
"slider.power.title" = "Vermogen";
|
||||
"slider.voltage.title" = "Spanning";
|
||||
"system.list.no.components" = "Nog geen componenten";
|
||||
"units.imperial.display" = "Imperiaal (AWG, ft)";
|
||||
"units.metric.display" = "Metrisch (mm², m)";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systemen";
|
||||
"System" = "Systeem";
|
||||
"System View" = "Systeemweergave";
|
||||
"System Name" = "Systeemnaam";
|
||||
"Create System" = "Systeem maken";
|
||||
"Create your first system" = "Maak je eerste systeem";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Geef je installatie een naam zodat **Cable by VoltPlan** lasten, bekabeling en aanbevelingen op één plek kan organiseren.";
|
||||
"Add your first component" = "Voeg je eerste component toe";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Breng je systeem tot leven met componenten en laat **Cable by VoltPlan** de kabel- en zekeringadviezen verzorgen.";
|
||||
"Create Component" = "Component maken";
|
||||
"Browse Library" = "Bibliotheek doorbladeren";
|
||||
"Browse" = "Bladeren";
|
||||
"Browse electrical components from VoltPlan" = "Blader door elektrische componenten van VoltPlan";
|
||||
"Component Library" = "Componentenbibliotheek";
|
||||
"Details coming soon" = "Details volgen binnenkort";
|
||||
"Components" = "Componenten";
|
||||
"FUSE" = "ZEKERING";
|
||||
"WIRE" = "KABEL";
|
||||
"Current" = "Stroom";
|
||||
"Power" = "Vermogen";
|
||||
"Voltage" = "Spanning";
|
||||
"Length" = "Lengte";
|
||||
"Length:" = "Lengte:";
|
||||
"Wire Cross-Section:" = "Kabeldoorsnede:";
|
||||
"Current Units" = "Huidige eenheden";
|
||||
"Unit System" = "Eenheidssysteem";
|
||||
"Units" = "Eenheden";
|
||||
"Settings" = "Instellingen";
|
||||
"Close" = "Sluiten";
|
||||
"Cancel" = "Annuleren";
|
||||
"Save" = "Opslaan";
|
||||
"Retry" = "Opnieuw";
|
||||
"Loading components" = "Componenten laden";
|
||||
"Unable to load components" = "Componenten konden niet worden geladen";
|
||||
"No components available" = "Geen componenten beschikbaar";
|
||||
"No matches" = "Geen overeenkomsten";
|
||||
"Check back soon for new loads from VoltPlan." = "Kom binnenkort terug voor nieuwe lasten van VoltPlan.";
|
||||
"Try searching for a different name." = "Probeer een andere zoekterm.";
|
||||
"Search components" = "Componenten zoeken";
|
||||
"No loads saved in this system yet." = "Er zijn nog geen lasten in dit systeem opgeslagen.";
|
||||
"Coming soon - manage your electrical systems and panels here." = "Binnenkort beschikbaar – beheer hier je elektrische systemen en schakelkasten.";
|
||||
"Load Library" = "Lastenbibliotheek";
|
||||
"Safety Disclaimer" = "Veiligheidswaarschuwing";
|
||||
"This application provides electrical calculations for educational and estimation purposes only." = "Deze app levert elektrische berekeningen uitsluitend voor educatieve doeleinden en schattingen.";
|
||||
"Important:" = "Belangrijk:";
|
||||
"• Always consult qualified electricians for actual installations" = "• Raadpleeg voor echte installaties altijd een gekwalificeerde elektricien";
|
||||
"• Follow all local electrical codes and regulations" = "• Volg alle lokale elektrische voorschriften en regels";
|
||||
"• Electrical work should only be performed by licensed professionals" = "• Elektrisch werk mag alleen worden uitgevoerd door bevoegde professionals";
|
||||
"• These calculations may not account for all environmental factors" = "• Deze berekeningen houden mogelijk niet met alle omgevingsfactoren rekening";
|
||||
"• The app developers assume no liability for electrical installations" = "• De ontwikkelaars van de app aanvaarden geen aansprakelijkheid voor elektrische installaties";
|
||||
"Enter length in %@" = "Voer de lengte in in %@";
|
||||
"Enter voltage in volts (V)" = "Voer de spanning in in volt (V)";
|
||||
"Enter current in amperes (A)" = "Voer de stroom in in ampère (A)";
|
||||
"Enter power in watts (W)" = "Voer het vermogen in in watt (W)";
|
||||
"Edit Length" = "Lengte bewerken";
|
||||
"Edit Voltage" = "Spanning bewerken";
|
||||
"Edit Current" = "Stroom bewerken";
|
||||
"Edit Power" = "Vermogen bewerken";
|
||||
"Preview" = "Voorbeeld";
|
||||
"Details" = "Details";
|
||||
"Icon" = "Pictogram";
|
||||
"Color" = "Kleur";
|
||||
"VoltPlan Library" = "VoltPlan-bibliotheek";
|
||||
"New Load" = "Nieuwe last";
|
||||
22
Cable/nl.lproj/Localizable.stringsdict
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>system.list.component.summary</key>
|
||||
<dict>
|
||||
<key>NSStringLocalizedFormatKey</key>
|
||||
<string>%#@component_count@ • %@</string>
|
||||
<key>component_count</key>
|
||||
<dict>
|
||||
<key>NSStringFormatSpecTypeKey</key>
|
||||
<string>NSStringPluralRuleType</string>
|
||||
<key>NSStringFormatValueTypeKey</key>
|
||||
<string>d</string>
|
||||
<key>one</key>
|
||||
<string>%d component</string>
|
||||
<key>other</key>
|
||||
<string>%d componenten</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
24
CableScreenshots.xctestplan
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"configurations" : [
|
||||
{
|
||||
"id" : "4CA9C9AC-F09F-48D8-9822-3CB6AF4C6D36",
|
||||
"name" : "Configuration 1",
|
||||
"options" : {
|
||||
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaultOptions" : {
|
||||
|
||||
},
|
||||
"testTargets" : [
|
||||
{
|
||||
"target" : {
|
||||
"containerPath" : "container:Cable.xcodeproj",
|
||||
"identifier" : "3E37F6542E93FB6F00836187",
|
||||
"name" : "CableUITestsScreenshot"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 1
|
||||
}
|
||||
41
CableUITestsScreenshot/CableUITestsScreenshot.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// CableUITestsScreenshot.swift
|
||||
// CableUITestsScreenshot
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshot: XCTestCase {
|
||||
|
||||
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.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
// @MainActor
|
||||
// func testLaunchPerformance() throws {
|
||||
// // This measures how long it takes to launch your application.
|
||||
// measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
// XCUIApplication().launch()
|
||||
// }
|
||||
// }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// CableUITestsScreenshotLaunchTests.swift
|
||||
// CableUITestsScreenshot
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testOnboardingLoadsView() throws {
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
app.launch()
|
||||
snapshot("0OnboardingSystemsView")
|
||||
let createSystemButton = app.buttons["create-system-button"]
|
||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||
createSystemButton.tap()
|
||||
|
||||
snapshot("1OnboardingLoadsView")
|
||||
let createComponentButton = app.buttons["create-component-button"]
|
||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||
createComponentButton.tap()
|
||||
snapshot("2LoadEditorView")
|
||||
}
|
||||
}
|
||||
313
CableUITestsScreenshot/SnapshotHelper.swift
Normal file
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// 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]
|
||||
229
Gemfile.lock
Normal file
@@ -0,0 +1,229 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1168.0)
|
||||
aws-sdk-core (3.233.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.113.0)
|
||||
aws-sdk-core (~> 3, >= 3.231.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.199.1)
|
||||
aws-sdk-core (~> 3, >= 3.231.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (3.2.3)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
faraday-httpclient (~> 1.0)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
faraday-net_http_persistent (~> 1.0)
|
||||
faraday-patron (~> 1.0)
|
||||
faraday-rack (~> 1.0)
|
||||
faraday-retry (~> 1.0)
|
||||
ruby2_keywords (>= 0.0.4)
|
||||
faraday-cookie_jar (0.0.7)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (~> 1.0.0)
|
||||
faraday-em_http (1.0.0)
|
||||
faraday-em_synchrony (1.0.1)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.1.1)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.228.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
excon (>= 0.71.0, < 1.0.0)
|
||||
faraday (~> 1.0)
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
mini_mime (~> 1.0)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.15.0)
|
||||
jwt (2.10.2)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.17.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
nkf (0.2.0)
|
||||
optparse (0.6.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.2)
|
||||
rake (13.3.0)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.21.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
multi_json (~> 1.10)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-24
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.2
|
||||
6
fastlane/Appfile
Normal file
@@ -0,0 +1,6 @@
|
||||
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
|
||||
23
fastlane/Fastfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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
|
||||
32
fastlane/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
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).
|
||||
54
fastlane/Snapfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# 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
|
||||
313
fastlane/SnapshotHelper.swift
Normal file
@@ -0,0 +1,313 @@
|
||||
//
|
||||
// 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]
|
||||
18
fastlane/report.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?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>
|
||||