Compare commits
2 Commits
cfcaab149f
...
420a6ea014
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420a6ea014 | ||
|
|
dd13178f0e |
2
.bundle/config
Normal file
@@ -0,0 +1,2 @@
|
||||
---
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
||||
.DS_*
|
||||
fastlane/screenshots
|
||||
xcshareddata
|
||||
xcshareddata
|
||||
Vendor
|
||||
Shots
|
||||
*.xcresult
|
||||
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
||||
3.2.4
|
||||
@@ -403,9 +403,10 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
@@ -421,9 +422,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.21;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -436,9 +438,10 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
@@ -454,9 +457,10 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.21;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.voltplan.CableApp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
@@ -516,7 +520,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
@@ -575,7 +579,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.5;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
|
||||
BIN
Cable/AppIcon copy.icon/Assets/body 3.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
Cable/AppIcon copy.icon/Assets/fuse-top.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Cable/AppIcon copy.icon/Assets/legs 2.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
295
Cable/AppIcon copy.icon/icon.json
Normal file
@@ -0,0 +1,295 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "extended-gray:1.00000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"solid" : "display-p3:0.31812,0.56494,0.59766,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"image-name" : "fuse-top.png",
|
||||
"name" : "fuse-top",
|
||||
"opacity" : 1,
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-225.390625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "display-p3:0.31812,0.56494,0.59766,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "body 3.png",
|
||||
"name" : "body 3",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"value" : 1
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-74.9921875
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group",
|
||||
"opacity" : 0.8,
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : true
|
||||
}
|
||||
],
|
||||
"image-name" : "legs 2.png",
|
||||
"name" : "legs 2",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
100
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "combined",
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"image-name" : "fuse-top.png",
|
||||
"name" : "fuse-top",
|
||||
"opacity" : 1,
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-225.390625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:1.00000,0.57811,0.00000,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "body 3.png",
|
||||
"name" : "body 3",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"value" : 1
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-74.9921875
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group",
|
||||
"opacity" : 0.8,
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
65.078125,
|
||||
-49.375
|
||||
]
|
||||
},
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : true
|
||||
}
|
||||
],
|
||||
"image-name" : "legs 2.png",
|
||||
"name" : "legs 2",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
-14.34375,
|
||||
117.640625
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "combined",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
73.96875,
|
||||
-62.640625
|
||||
]
|
||||
},
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
BIN
Cable/AppIcon.icon/Assets/body 3.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 71 KiB |
BIN
Cable/AppIcon.icon/Assets/flash.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
Cable/AppIcon.icon/Assets/fuse-top.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Cable/AppIcon.icon/Assets/legs 2.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 23 KiB |
@@ -1,63 +1,171 @@
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.50588,0.79216,0.56471,1.00000"
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.92941,1.00000,0.92941,1.00000",
|
||||
"extended-gray:1.00000,1.00000"
|
||||
]
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"blur-material" : 0.9,
|
||||
"layers" : [
|
||||
{
|
||||
"blend-mode" : "normal",
|
||||
"glass" : false,
|
||||
"hidden" : false,
|
||||
"image-name" : "voltplan-lines.png",
|
||||
"name" : "voltplan-lines",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
80.8265625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:1.00000,1.00000,1.00000,1.00000",
|
||||
"srgb:1.00000,1.00000,1.00000,0.63382"
|
||||
]
|
||||
"solid" : "srgb:1.00000,0.57811,0.00000,1.00000"
|
||||
},
|
||||
"image-name" : "voltplan-logo 2 2.png",
|
||||
"name" : "voltplan-logo 2 2",
|
||||
"glass" : true,
|
||||
"hidden" : true,
|
||||
"image-name" : "flash.png",
|
||||
"name" : "flash",
|
||||
"opacity" : 1,
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"scale" : 0.8,
|
||||
"translation-in-points" : [
|
||||
-3,
|
||||
77.55625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"srgb:1.00000,1.00000,1.00000,1.00000",
|
||||
"srgb:1.00000,1.00000,1.00000,0.50000"
|
||||
]
|
||||
},
|
||||
"image-name" : "box-2.png",
|
||||
"name" : "Layer",
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-91.0703125
|
||||
7.4921875,
|
||||
-218.84375
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blur-material" : null,
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"solid" : "display-p3:0.31812,0.56494,0.59766,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"image-name" : "fuse-top.png",
|
||||
"name" : "fuse-top",
|
||||
"opacity" : 1,
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-225.390625
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "display-p3:0.31812,0.56494,0.59766,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.83742,0.83744,0.83743,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass" : true,
|
||||
"hidden" : false,
|
||||
"image-name" : "body 3.png",
|
||||
"name" : "body 3",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"value" : 1
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-74.9921875
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "individual",
|
||||
"name" : "Group",
|
||||
"opacity" : 0.8,
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"hidden" : false,
|
||||
"layers" : [
|
||||
{
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.37056,0.37056,0.37056,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "dark",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : {
|
||||
"automatic-gradient" : "srgb:0.75407,0.75409,0.75408,1.00000"
|
||||
}
|
||||
}
|
||||
],
|
||||
"glass-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : true
|
||||
}
|
||||
],
|
||||
"image-name" : "legs 2.png",
|
||||
"name" : "legs 2",
|
||||
"opacity-specializations" : [
|
||||
{
|
||||
"appearance" : "tinted",
|
||||
"value" : 1
|
||||
}
|
||||
],
|
||||
"position" : {
|
||||
"scale" : 0.9,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
100
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"lighting" : "combined",
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular" : false,
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
|
||||
@@ -35,3 +35,35 @@
|
||||
"system.list.no.components" = "No components yet";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Metric (mm², m)";
|
||||
"sample.system.rv.name" = "Adventure Van";
|
||||
"sample.system.rv.location" = "12V living circuit";
|
||||
"sample.system.workshop.name" = "Workshop Bench";
|
||||
"sample.system.workshop.location" = "Tool corner";
|
||||
"sample.load.fridge.name" = "Compressor fridge";
|
||||
"sample.load.lighting.name" = "LED strip lighting";
|
||||
"sample.load.compressor.name" = "Air compressor";
|
||||
"sample.load.charger.name" = "Tool charger";
|
||||
"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach";
|
||||
"system.icon.keywords.truck" = "truck, trailer, rig";
|
||||
"system.icon.keywords.boat" = "boat, marine, yacht, sail";
|
||||
"system.icon.keywords.plane" = "plane, air, flight";
|
||||
"system.icon.keywords.ferry" = "ferry, ship";
|
||||
"system.icon.keywords.house" = "house, home, cabin, cottage, lodge";
|
||||
"system.icon.keywords.building" = "building, office, warehouse, factory, facility";
|
||||
"system.icon.keywords.tent" = "camp, tent, outdoor";
|
||||
"system.icon.keywords.solar" = "solar, sun";
|
||||
"system.icon.keywords.battery" = "battery, storage";
|
||||
"system.icon.keywords.server" = "server, data, network, rack";
|
||||
"system.icon.keywords.computer" = "computer, electronics, lab, tech";
|
||||
"system.icon.keywords.gear" = "gear, mechanic, machine, workshop";
|
||||
"system.icon.keywords.tool" = "tool, maintenance, repair, shop";
|
||||
"system.icon.keywords.hammer" = "hammer, carpentry";
|
||||
"system.icon.keywords.light" = "light, lighting, lamp";
|
||||
"system.icon.keywords.bolt" = "bolt, power, electric";
|
||||
"system.icon.keywords.plug" = "plug";
|
||||
"system.icon.keywords.engine" = "engine, generator, motor";
|
||||
"system.icon.keywords.fuel" = "fuel, diesel, gas";
|
||||
"system.icon.keywords.water" = "water, pump, tank";
|
||||
"system.icon.keywords.heat" = "heat, heater, furnace";
|
||||
"system.icon.keywords.cold" = "cold, freeze, cool";
|
||||
"system.icon.keywords.climate" = "climate, hvac, temperature";
|
||||
|
||||
@@ -30,6 +30,12 @@ struct CableApp: App {
|
||||
}
|
||||
}()
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -9,6 +9,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
|
||||
let id: String
|
||||
let name: String
|
||||
let translations: [String: String]
|
||||
let voltageIn: Double?
|
||||
let voltageOut: Double?
|
||||
let watt: Double?
|
||||
@@ -39,10 +40,24 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
return String(format: "%.1fA", current)
|
||||
}
|
||||
|
||||
var localizedName: String {
|
||||
localizedName(usingPreferredLanguages: Locale.preferredLanguages) ?? name
|
||||
}
|
||||
|
||||
func localizedName(usingPreferredLanguages languages: [String]) -> String? {
|
||||
guard let primaryIdentifier = languages.first else { return nil }
|
||||
let locale = Locale(identifier: primaryIdentifier)
|
||||
return translation(for: locale)
|
||||
}
|
||||
|
||||
var primaryAffiliateLink: AffiliateLink? {
|
||||
affiliateLink(matching: Locale.current.region?.identifier)
|
||||
}
|
||||
|
||||
func localizedName(for locale: Locale) -> String {
|
||||
translation(for: locale) ?? name
|
||||
}
|
||||
|
||||
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
|
||||
guard !affiliateLinks.isEmpty else { return nil }
|
||||
|
||||
@@ -64,6 +79,108 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
|
||||
return affiliateLinks.first
|
||||
}
|
||||
|
||||
private func translation(for locale: Locale) -> String? {
|
||||
guard !translations.isEmpty else { return nil }
|
||||
|
||||
let lookupKeys = ComponentLibraryItem.lookupKeys(for: locale)
|
||||
|
||||
for key in lookupKeys {
|
||||
if let match = translations[key] {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedTranslations = translations.reduce(into: [String: String]()) { result, element in
|
||||
let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(element.key)
|
||||
result[normalizedKey] = element.value
|
||||
|
||||
if let languageOnlyKey = ComponentLibraryItem.languageComponent(fromNormalizedKey: normalizedKey),
|
||||
result[languageOnlyKey] == nil {
|
||||
result[languageOnlyKey] = element.value
|
||||
}
|
||||
}
|
||||
|
||||
for key in lookupKeys {
|
||||
let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(key)
|
||||
if let match = normalizedTranslations[normalizedKey] {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func lookupKeys(for locale: Locale) -> [String] {
|
||||
var keys: [String] = []
|
||||
|
||||
func append(_ value: String?) {
|
||||
guard let value, !value.isEmpty else { return }
|
||||
|
||||
for variant in variants(for: value) {
|
||||
if !keys.contains(variant) {
|
||||
keys.append(variant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
append(locale.identifier)
|
||||
|
||||
let components = Locale.components(fromIdentifier: locale.identifier)
|
||||
|
||||
if let language = components[NSLocale.Key.languageCode.rawValue]?.lowercased() {
|
||||
if let region = components[NSLocale.Key.countryCode.rawValue]?.uppercased() {
|
||||
append("\(language)_\(region)")
|
||||
}
|
||||
append(language)
|
||||
}
|
||||
|
||||
if let languageCode = locale.language.languageCode?.identifier.lowercased() {
|
||||
append(languageCode)
|
||||
}
|
||||
|
||||
if let regionIdentifier = locale.region?.identifier.uppercased(),
|
||||
let languageIdentifier = locale.language.languageCode?.identifier.lowercased() {
|
||||
append("\(languageIdentifier)_\(regionIdentifier)")
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
private static func normalizeLocaleKey(_ key: String) -> String {
|
||||
let sanitized = key.replacingOccurrences(of: "-", with: "_")
|
||||
let parts = sanitized.split(separator: "_", omittingEmptySubsequences: true)
|
||||
|
||||
guard let languagePart = parts.first else {
|
||||
return sanitized.lowercased()
|
||||
}
|
||||
|
||||
let language = languagePart.lowercased()
|
||||
|
||||
if parts.count >= 2, let regionPart = parts.last {
|
||||
return "\(language)_\(regionPart.uppercased())"
|
||||
}
|
||||
|
||||
return language
|
||||
}
|
||||
|
||||
private static func languageComponent(fromNormalizedKey key: String) -> String? {
|
||||
let components = key.split(separator: "_", omittingEmptySubsequences: true)
|
||||
guard let language = components.first else { return nil }
|
||||
return String(language)
|
||||
}
|
||||
|
||||
private static func variants(for key: String) -> [String] {
|
||||
var collected: [String] = []
|
||||
let underscore = key.replacingOccurrences(of: "-", with: "_")
|
||||
let hyphen = key.replacingOccurrences(of: "_", with: "-")
|
||||
|
||||
for candidate in Set([key, underscore, hyphen]) {
|
||||
collected.append(candidate)
|
||||
}
|
||||
|
||||
return collected
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -113,7 +230,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "filter", value: "(type='load')"),
|
||||
URLQueryItem(name: "sort", value: "+name"),
|
||||
URLQueryItem(name: "fields", value: "id,collectionId,name,icon,voltage_in,voltage_out,watt"),
|
||||
URLQueryItem(name: "fields", value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt"),
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "perPage", value: "\(perPage)")
|
||||
]
|
||||
@@ -153,6 +270,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
ComponentLibraryItem(
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
translations: record.translations?.flattened ?? [:],
|
||||
voltageIn: record.voltageIn,
|
||||
voltageOut: record.voltageOut,
|
||||
watt: record.watt,
|
||||
@@ -300,6 +418,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
let id: String
|
||||
let collectionId: String
|
||||
let name: String
|
||||
let translations: TranslationsContainer?
|
||||
let icon: String?
|
||||
let voltageIn: Double?
|
||||
let voltageOut: Double?
|
||||
@@ -309,11 +428,64 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
case id
|
||||
case collectionId
|
||||
case name
|
||||
case translations
|
||||
case icon
|
||||
case voltageIn = "voltage_in"
|
||||
case voltageOut = "voltage_out"
|
||||
case watt
|
||||
}
|
||||
|
||||
struct TranslationsContainer: Decodable {
|
||||
private let storage: [String: TranslationValue]
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
storage = try container.decode([String: TranslationValue].self)
|
||||
}
|
||||
|
||||
var flattened: [String: String] {
|
||||
storage.reduce(into: [:]) { result, entry in
|
||||
if let value = entry.value.flattened {
|
||||
result[entry.key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TranslationValue: Decodable {
|
||||
case string(String)
|
||||
case dictionary([String: String])
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let singleValue = try decoder.singleValueContainer()
|
||||
if let string = try? singleValue.decode(String.self) {
|
||||
self = .string(string)
|
||||
return
|
||||
}
|
||||
|
||||
if let dictionary = try? singleValue.decode([String: String].self) {
|
||||
self = .dictionary(dictionary)
|
||||
return
|
||||
}
|
||||
|
||||
self = .dictionary([:])
|
||||
}
|
||||
|
||||
var flattened: String? {
|
||||
switch self {
|
||||
case .string(let value):
|
||||
return value.isEmpty ? nil : value
|
||||
case .dictionary(let dictionary):
|
||||
if let name = dictionary["name"], !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
if let value = dictionary["value"], !value.isEmpty {
|
||||
return value
|
||||
}
|
||||
return dictionary.values.first(where: { !$0.isEmpty })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AffiliateLinksResponse: Decodable {
|
||||
@@ -346,6 +518,7 @@ struct ComponentLibraryView: View {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier("library-view-close-button")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -395,14 +568,17 @@ struct ComponentLibraryView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
} else {
|
||||
List(filteredItems) { item in
|
||||
Button {
|
||||
onSelect(item)
|
||||
dismiss()
|
||||
} label: {
|
||||
ComponentRow(item: item)
|
||||
List {
|
||||
ForEach(filteredItems) { item in
|
||||
Button {
|
||||
onSelect(item)
|
||||
dismiss()
|
||||
} label: {
|
||||
ComponentRow(item: item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
poweredByVoltplanRow
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
@@ -413,7 +589,30 @@ struct ComponentLibraryView: View {
|
||||
guard !trimmedQuery.isEmpty else { return viewModel.items }
|
||||
|
||||
return viewModel.items.filter { item in
|
||||
item.name.localizedCaseInsensitiveContains(trimmedQuery)
|
||||
let localizedName = item.localizedName
|
||||
return localizedName.localizedCaseInsensitiveContains(trimmedQuery)
|
||||
|| item.name.localizedCaseInsensitiveContains(trimmedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var poweredByVoltplanRow: some View {
|
||||
if let url = URL(string: "https://voltplan.app") {
|
||||
Section {
|
||||
Link(destination: url) {
|
||||
Image("PoweredByVoltplan")
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 220)
|
||||
.padding(.vertical, 20)
|
||||
.frame(maxWidth: .infinity)
|
||||
.accessibilityLabel("Powered by Voltplan")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -425,7 +624,7 @@ private struct ComponentRow: View {
|
||||
HStack(spacing: 12) {
|
||||
iconView
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.name)
|
||||
Text(item.localizedName)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
detailLine
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ComponentsOnboardingView: View {
|
||||
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("select-component-button")
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
387
Cable/LoadsView.swift
Normal file
@@ -0,0 +1,387 @@
|
||||
//
|
||||
// LoadsView.swift
|
||||
// Cable
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct LoadsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
|
||||
@State private var newLoadToEdit: SavedLoad?
|
||||
@State private var showingSystemEditor = false
|
||||
@State private var hasPresentedSystemEditorOnAppear = false
|
||||
@State private var hasOpenedLoadOnAppear = false
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSystemBOM = false
|
||||
|
||||
let system: ElectricalSystem
|
||||
private let presentSystemEditorOnAppear: Bool
|
||||
private let loadToOpenOnAppear: SavedLoad?
|
||||
|
||||
init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil) {
|
||||
self.system = system
|
||||
self.presentSystemEditorOnAppear = presentSystemEditorOnAppear
|
||||
self.loadToOpenOnAppear = loadToOpenOnAppear
|
||||
}
|
||||
|
||||
private var savedLoads: [SavedLoad] {
|
||||
allLoads.filter { $0.system == system }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if savedLoads.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
librarySection
|
||||
|
||||
List {
|
||||
ForEach(savedLoads) { load in
|
||||
NavigationLink(destination: CalculatorView(savedLoad: load)) {
|
||||
HStack(spacing: 12) {
|
||||
LoadIconView(
|
||||
remoteIconURLString: load.remoteIconURLString,
|
||||
fallbackSystemName: load.iconName,
|
||||
fallbackColor: colorForName(load.colorName),
|
||||
size: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(load.name)
|
||||
.fontWeight(.medium)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
// Secondary info
|
||||
HStack {
|
||||
Group {
|
||||
Text(String(format: "%.1fV", load.voltage))
|
||||
Text("•")
|
||||
if load.isWattMode {
|
||||
Text(String(format: "%.0fW", load.power))
|
||||
} else {
|
||||
Text(String(format: "%.1fA", load.current))
|
||||
}
|
||||
Text("•")
|
||||
Text(String(format: "%.1f%@",
|
||||
unitSettings.unitSystem == .metric ? load.length : load.length * 3.28084,
|
||||
unitSettings.unitSystem.lengthUnit))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Prominent fuse and wire gauge display
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Text("FUSE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(recommendedFuse(for: load))A")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Text("WIRE")
|
||||
.font(.caption2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
Text(String(format: unitSettings.unitSystem == .imperial ? "%.0f AWG" : "%.1fmm²",
|
||||
unitSettings.unitSystem == .imperial ? awgFromCrossSection(load.crossSection) : load.crossSection))
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteLoads)
|
||||
}
|
||||
.accessibilityIdentifier("loads-list")
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Button(action: {
|
||||
showingSystemEditor = true
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(colorForName(system.colorName))
|
||||
.frame(width: 24, height: 24)
|
||||
|
||||
Image(systemName: system.iconName)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
Text(system.name)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
if !savedLoads.isEmpty {
|
||||
Button(action: {
|
||||
showingSystemBOM = true
|
||||
}) {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
}
|
||||
.accessibilityIdentifier("system-bom-button")
|
||||
}
|
||||
Button(action: {
|
||||
createNewLoad()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $newLoadToEdit) { load in
|
||||
CalculatorView(savedLoad: load)
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
addComponent(item)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSystemBOM) {
|
||||
SystemBillOfMaterialsView(
|
||||
systemName: system.name,
|
||||
loads: savedLoads,
|
||||
unitSystem: unitSettings.unitSystem
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingSystemEditor) {
|
||||
SystemEditorView(
|
||||
systemName: Binding(
|
||||
get: { system.name },
|
||||
set: { system.name = $0 }
|
||||
),
|
||||
location: Binding(
|
||||
get: { system.location },
|
||||
set: { system.location = $0 }
|
||||
),
|
||||
iconName: Binding(
|
||||
get: { system.iconName },
|
||||
set: { system.iconName = $0 }
|
||||
),
|
||||
colorName: Binding(
|
||||
get: { system.colorName },
|
||||
set: { system.colorName = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||
hasPresentedSystemEditorOnAppear = true
|
||||
DispatchQueue.main.async {
|
||||
showingSystemEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
||||
hasOpenedLoadOnAppear = true
|
||||
DispatchQueue.main.async {
|
||||
newLoadToEdit = loadToOpen
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var librarySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Component Library")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
Text("Browse electrical components from VoltPlan")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
showingComponentLibrary = true
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Browse")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.systemGroupedBackground))
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
ComponentsOnboardingView(
|
||||
onCreate: { createNewLoad() },
|
||||
onBrowse: { showingComponentLibrary = true }
|
||||
)
|
||||
}
|
||||
|
||||
private func deleteLoads(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(savedLoads[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewLoad() {
|
||||
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,
|
||||
current: 5.0,
|
||||
power: 60.0, // 12V * 5A = 60W
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: false,
|
||||
system: system,
|
||||
remoteIconURLString: nil
|
||||
)
|
||||
modelContext.insert(newLoad)
|
||||
|
||||
// Navigate to the new load
|
||||
newLoadToEdit = newLoad
|
||||
}
|
||||
|
||||
private func addComponent(_ item: ComponentLibraryItem) {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
|
||||
let loadName = uniqueLoadName(startingWith: baseName)
|
||||
let voltage = item.displayVoltage ?? 12.0
|
||||
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
|
||||
let current: Double
|
||||
if let explicitCurrent = item.current {
|
||||
current = explicitCurrent
|
||||
} else if voltage > 0 {
|
||||
current = power / voltage
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: power,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: item.watt != nil,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
)
|
||||
|
||||
modelContext.insert(newLoad)
|
||||
newLoadToEdit = newLoad
|
||||
}
|
||||
|
||||
private func uniqueLoadName(startingWith baseName: String) -> String {
|
||||
let existingNames = Set(savedLoads.map { $0.name })
|
||||
|
||||
if !existingNames.contains(baseName) {
|
||||
return baseName
|
||||
}
|
||||
|
||||
var counter = 2
|
||||
var candidate = "\(baseName) \(counter)"
|
||||
|
||||
while existingNames.contains(candidate) {
|
||||
counter += 1
|
||||
candidate = "\(baseName) \(counter)"
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func colorForName(_ colorName: String) -> Color {
|
||||
switch colorName {
|
||||
case "blue": return .blue
|
||||
case "green": return .green
|
||||
case "orange": return .orange
|
||||
case "red": return .red
|
||||
case "purple": return .purple
|
||||
case "yellow": return .yellow
|
||||
case "pink": return .pink
|
||||
case "teal": return .teal
|
||||
case "indigo": return .indigo
|
||||
case "mint": return .mint
|
||||
case "cyan": return .cyan
|
||||
case "brown": return .brown
|
||||
case "gray": return .gray
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
|
||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||
let awgSizes = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31),
|
||||
(10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
|
||||
(1, 42.4), (0, 53.5), (00, 67.4), (000, 85.0), (0000, 107.0)]
|
||||
|
||||
// Find the closest AWG size
|
||||
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
|
||||
return Double(closest?.0 ?? 20)
|
||||
}
|
||||
|
||||
private func recommendedFuse(for load: SavedLoad) -> Int {
|
||||
let targetFuse = load.current * 1.25 // 125% of load current for safety
|
||||
|
||||
// Common fuse values in amperes
|
||||
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
||||
|
||||
// Find the smallest standard fuse that's >= target
|
||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
|
||||
}
|
||||
}
|
||||
91
Cable/SettingsView.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// Cable
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Units") {
|
||||
Picker("Unit System", selection: $unitSettings.unitSystem) {
|
||||
ForEach(UnitSystem.allCases, id: \.self) { system in
|
||||
Text(system.displayName).tag(system)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Wire Cross-Section:")
|
||||
Spacer()
|
||||
Text(unitSettings.unitSystem.wireAreaUnit)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Length:")
|
||||
Spacer()
|
||||
Text(unitSettings.unitSystem.lengthUnit)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Text("Current Units")
|
||||
} footer: {
|
||||
Text("Changing the unit system will apply to all calculations in the app.")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.system(size: 18))
|
||||
Text("Safety Disclaimer")
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This application provides electrical calculations for educational and estimation purposes only.")
|
||||
.font(.body)
|
||||
|
||||
Text("Important:")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("• Always consult qualified electricians for actual installations")
|
||||
Text("• Follow all local electrical codes and regulations")
|
||||
Text("• Electrical work should only be performed by licensed professionals")
|
||||
Text("• These calculations may not account for all environmental factors")
|
||||
Text("• The app developers assume no liability for electrical installations")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
382
Cable/SystemBillOfMaterialsView.swift
Normal file
@@ -0,0 +1,382 @@
|
||||
//
|
||||
// SystemBillOfMaterialsView.swift
|
||||
// Cable
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SystemBillOfMaterialsView: View {
|
||||
let systemName: String
|
||||
let loads: [SavedLoad]
|
||||
let unitSystem: UnitSystem
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.openURL) private var openURL
|
||||
@State private var completedItemIDs: Set<String>
|
||||
@State private var suppressRowTapForID: String?
|
||||
|
||||
private struct Item: Identifiable {
|
||||
enum Destination {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let logicalID: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let iconSystemName: String
|
||||
let destination: Destination
|
||||
let isPrimaryComponent: Bool
|
||||
}
|
||||
|
||||
init(systemName: String, loads: [SavedLoad], unitSystem: UnitSystem) {
|
||||
self.systemName = systemName
|
||||
self.loads = loads
|
||||
self.unitSystem = unitSystem
|
||||
let initialKeys = loads.flatMap { load in
|
||||
load.bomCompletedItemIDs.map { SystemBillOfMaterialsView.storageKey(for: load, itemID: $0) }
|
||||
}
|
||||
_completedItemIDs = State(initialValue: Set(initialKeys))
|
||||
_suppressRowTapForID = State(initialValue: nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
if sortedLoads.isEmpty {
|
||||
Section("Components") {
|
||||
Text("No loads saved in this system yet.")
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ForEach(sortedLoads) { load in
|
||||
Section(header: sectionHeader(for: load)) {
|
||||
ForEach(items(for: load)) { item in
|
||||
let isCompleted = completedItemIDs.contains(item.id)
|
||||
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)
|
||||
.onTapGesture {
|
||||
setCompletion(!isCompleted, for: load, item: item)
|
||||
suppressRowTapForID = item.id
|
||||
}
|
||||
.accessibilityLabel(accessibilityLabel)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(isCompleted ? Color.primary.opacity(0.7) : Color.primary)
|
||||
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
|
||||
|
||||
if item.isPrimaryComponent {
|
||||
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)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.15), in: Capsule())
|
||||
}
|
||||
|
||||
Text(item.detail)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if destinationURL != nil {
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 16))
|
||||
.listRowBackground(
|
||||
Color(.secondarySystemGroupedBackground)
|
||||
)
|
||||
.onTapGesture {
|
||||
if suppressRowTapForID == item.id {
|
||||
suppressRowTapForID = nil
|
||||
return
|
||||
}
|
||||
if let destinationURL {
|
||||
openURL(destinationURL)
|
||||
}
|
||||
setCompletion(true, for: load, item: item)
|
||||
suppressRowTapForID = nil
|
||||
suppressRowTapForID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Text(footerMessage)
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.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) {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshCompletedItems()
|
||||
suppressRowTapForID = nil
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("system-bom-view")
|
||||
}
|
||||
|
||||
private var sortedLoads: [SavedLoad] {
|
||||
loads.sorted { lhs, rhs in
|
||||
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionHeader(for load: SavedLoad) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
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)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func items(for load: SavedLoad) -> [Item] {
|
||||
let lengthValue: Double
|
||||
if unitSystem == .imperial {
|
||||
lengthValue = load.length * 3.28084
|
||||
} else {
|
||||
lengthValue = load.length
|
||||
}
|
||||
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
||||
|
||||
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 = unknownSizeLabel
|
||||
gaugeQuery = "battery cable"
|
||||
}
|
||||
} else {
|
||||
if load.crossSection > 0 {
|
||||
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
|
||||
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
|
||||
} else {
|
||||
crossSectionLabel = unknownSizeLabel
|
||||
gaugeQuery = "battery cable"
|
||||
}
|
||||
}
|
||||
|
||||
let cableDetail = "\(lengthLabel) • \(crossSectionLabel)"
|
||||
|
||||
let calculatedPower = load.power > 0 ? load.power : load.voltage * load.current
|
||||
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
|
||||
|
||||
let fuseRating = recommendedFuse(for: load)
|
||||
let fuseDetailFormat = NSLocalizedString(
|
||||
"bom.fuse.detail",
|
||||
comment: "Description for the fuse item in the BOM list"
|
||||
)
|
||||
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
|
||||
|
||||
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
|
||||
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage)
|
||||
: load.name
|
||||
|
||||
let redCableQuery = "\(gaugeQuery) red battery cable"
|
||||
let blackCableQuery = "\(gaugeQuery) black battery cable"
|
||||
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
||||
let terminalQuery = "\(gaugeQuery) cable shoes"
|
||||
|
||||
let items: [Item] = [
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "component"),
|
||||
logicalID: "component",
|
||||
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),
|
||||
isPrimaryComponent: true
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "cable-red"),
|
||||
logicalID: "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),
|
||||
isPrimaryComponent: false
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "cable-black"),
|
||||
logicalID: "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),
|
||||
isPrimaryComponent: false
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "fuse"),
|
||||
logicalID: "fuse",
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
isPrimaryComponent: false
|
||||
),
|
||||
Item(
|
||||
id: Self.storageKey(for: load, itemID: "terminals"),
|
||||
logicalID: "terminals",
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
]
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private func destinationURL(for destination: Item.Destination, load: SavedLoad) -> URL? {
|
||||
switch destination {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
|
||||
}
|
||||
}
|
||||
|
||||
private static func storageKey(for load: SavedLoad, itemID: String) -> String {
|
||||
if load.identifier.isEmpty {
|
||||
load.identifier = UUID().uuidString
|
||||
}
|
||||
return "\(load.identifier)::\(itemID)"
|
||||
}
|
||||
|
||||
private func setCompletion(_ isCompleted: Bool, for load: SavedLoad, item: Item) {
|
||||
if isCompleted {
|
||||
completedItemIDs.insert(item.id)
|
||||
} else {
|
||||
completedItemIDs.remove(item.id)
|
||||
}
|
||||
|
||||
if load.identifier.isEmpty {
|
||||
load.identifier = UUID().uuidString
|
||||
}
|
||||
|
||||
var stored = Set(load.bomCompletedItemIDs)
|
||||
if isCompleted {
|
||||
stored.insert(item.logicalID)
|
||||
} else {
|
||||
stored.remove(item.logicalID)
|
||||
}
|
||||
load.bomCompletedItemIDs = Array(stored).sorted()
|
||||
}
|
||||
|
||||
private func refreshCompletedItems() {
|
||||
let keys = loads.flatMap { load in
|
||||
load.bomCompletedItemIDs.map { Self.storageKey(for: load, itemID: $0) }
|
||||
}
|
||||
completedItemIDs = Set(keys)
|
||||
}
|
||||
|
||||
private func recommendedFuse(for load: SavedLoad) -> Int {
|
||||
let targetFuse = load.current * 1.25
|
||||
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
||||
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last ?? 0
|
||||
}
|
||||
|
||||
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||
let mapping: [(awg: Double, area: Double)] = [
|
||||
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26),
|
||||
(8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (0, 53.5),
|
||||
(00, 67.4), (000, 85.0), (0000, 107.0)
|
||||
]
|
||||
|
||||
guard crossSectionMM2 > 0 else { return 0 }
|
||||
|
||||
let closest = mapping.min { lhs, rhs in
|
||||
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
|
||||
}
|
||||
|
||||
return closest?.awg ?? 0
|
||||
}
|
||||
|
||||
private var footerMessage: String {
|
||||
NSLocalizedString(
|
||||
"affiliate.disclaimer",
|
||||
comment: "Footer note reminding users that affiliate purchases may support the app"
|
||||
)
|
||||
}
|
||||
|
||||
private var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
40
Cable/SystemView.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// SystemView.swift
|
||||
// Cable
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SystemView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 24) {
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "square.grid.3x2")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("System View")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Coming soon - manage your electrical systems and panels here.")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 48)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("System")
|
||||
}
|
||||
}
|
||||
}
|
||||
404
Cable/SystemsView.swift
Normal file
@@ -0,0 +1,404 @@
|
||||
//
|
||||
// SystemsView.swift
|
||||
// Cable
|
||||
//
|
||||
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||
//
|
||||
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct SystemsView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
|
||||
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
|
||||
@State private var systemNavigationTarget: SystemNavigationTarget?
|
||||
@State private var showingComponentLibrary = false
|
||||
@State private var showingSettings = false
|
||||
|
||||
private let systemColorOptions = [
|
||||
"blue", "green", "orange", "red", "purple", "yellow",
|
||||
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
|
||||
]
|
||||
private let defaultSystemIconName = "building.2"
|
||||
private var systemIconMappings: [(keywords: [String], icon: String)] {
|
||||
[
|
||||
(keywords(for: "system.icon.keywords.rv", fallback: ["rv", "van", "camper", "motorhome", "coach"]), "bus"),
|
||||
(keywords(for: "system.icon.keywords.truck", fallback: ["truck", "trailer", "rig"]), "truck.box"),
|
||||
(keywords(for: "system.icon.keywords.boat", fallback: ["boat", "marine", "yacht", "sail"]), "sailboat"),
|
||||
(keywords(for: "system.icon.keywords.plane", fallback: ["plane", "air", "flight"]), "airplane"),
|
||||
(keywords(for: "system.icon.keywords.ferry", fallback: ["ferry", "ship"]), "ferry"),
|
||||
(keywords(for: "system.icon.keywords.house", fallback: ["house", "home", "cabin", "cottage", "lodge"]), "house"),
|
||||
(keywords(for: "system.icon.keywords.building", fallback: ["building", "office", "warehouse", "factory", "facility"]), "building"),
|
||||
(keywords(for: "system.icon.keywords.tent", fallback: ["camp", "tent", "outdoor"]), "tent"),
|
||||
(keywords(for: "system.icon.keywords.solar", fallback: ["solar", "sun"]), "sun.max"),
|
||||
(keywords(for: "system.icon.keywords.battery", fallback: ["battery", "storage"]), "battery.100"),
|
||||
(keywords(for: "system.icon.keywords.server", fallback: ["server", "data", "network", "rack"]), "server.rack"),
|
||||
(keywords(for: "system.icon.keywords.computer", fallback: ["computer", "electronics", "lab", "tech"]), "cpu"),
|
||||
(keywords(for: "system.icon.keywords.gear", fallback: ["gear", "mechanic", "machine", "workshop"]), "gear"),
|
||||
(keywords(for: "system.icon.keywords.tool", fallback: ["tool", "maintenance", "repair", "shop"]), "wrench.adjustable"),
|
||||
(keywords(for: "system.icon.keywords.hammer", fallback: ["hammer", "carpentry"]), "hammer"),
|
||||
(keywords(for: "system.icon.keywords.light", fallback: ["light", "lighting", "lamp"]), "lightbulb"),
|
||||
(keywords(for: "system.icon.keywords.bolt", fallback: ["bolt", "power", "electric"]), "bolt"),
|
||||
(keywords(for: "system.icon.keywords.plug", fallback: ["plug"]), "powerplug"),
|
||||
(keywords(for: "system.icon.keywords.engine", fallback: ["engine", "generator", "motor"]), "engine.combustion"),
|
||||
(keywords(for: "system.icon.keywords.fuel", fallback: ["fuel", "diesel", "gas"]), "fuelpump"),
|
||||
(keywords(for: "system.icon.keywords.water", fallback: ["water", "pump", "tank"]), "drop"),
|
||||
(keywords(for: "system.icon.keywords.heat", fallback: ["heat", "heater", "furnace"]), "flame"),
|
||||
(keywords(for: "system.icon.keywords.cold", fallback: ["cold", "freeze", "cool"]), "snowflake"),
|
||||
(keywords(for: "system.icon.keywords.climate", fallback: ["climate", "hvac", "temperature"]), "thermometer")
|
||||
]
|
||||
}
|
||||
|
||||
private struct SystemNavigationTarget: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let system: ElectricalSystem
|
||||
let presentSystemEditor: Bool
|
||||
let loadToOpenOnAppear: SavedLoad?
|
||||
|
||||
static func == (lhs: SystemNavigationTarget, rhs: SystemNavigationTarget) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if systems.isEmpty {
|
||||
systemsEmptyState
|
||||
} else {
|
||||
List {
|
||||
ForEach(systems) { system in
|
||||
NavigationLink(destination: LoadsView(system: system)) {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(colorForName(system.colorName))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: system.iconName)
|
||||
.font(.title3)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(system.name)
|
||||
.fontWeight(.medium)
|
||||
|
||||
if !system.location.isEmpty {
|
||||
Text(system.location)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Text(componentSummary(for: system))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteSystems)
|
||||
}
|
||||
.accessibilityIdentifier("systems-list")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Systems")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button {
|
||||
showingSettings = true
|
||||
} label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
HStack {
|
||||
Button(action: {
|
||||
createNewSystem()
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
EditButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $systemNavigationTarget) { target in
|
||||
LoadsView(
|
||||
system: target.system,
|
||||
presentSystemEditorOnAppear: target.presentSystemEditor,
|
||||
loadToOpenOnAppear: target.loadToOpenOnAppear
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingComponentLibrary) {
|
||||
ComponentLibraryView { item in
|
||||
addComponentFromLibrary(item)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView()
|
||||
.environmentObject(unitSettings)
|
||||
}
|
||||
}
|
||||
|
||||
private var systemsEmptyState: some View {
|
||||
SystemsOnboardingView { name in
|
||||
createOnboardingSystem(named: name)
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewSystem() {
|
||||
let system = makeSystem()
|
||||
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,
|
||||
presentSystemEditor: presentSystemEditor,
|
||||
loadToOpenOnAppear: loadToOpen
|
||||
)
|
||||
|
||||
if animated {
|
||||
systemNavigationTarget = target
|
||||
} else {
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
systemNavigationTarget = target
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
|
||||
let existingNames = Set(systems.map { $0.name })
|
||||
let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let baseName = trimmedPreferred.isEmpty ? String(localized: "default.system.new", comment: "Default name for a newly created system") : trimmedPreferred
|
||||
var systemName = baseName
|
||||
var counter = 2
|
||||
|
||||
while existingNames.contains(systemName) {
|
||||
systemName = "\(baseName) \(counter)"
|
||||
counter += 1
|
||||
}
|
||||
|
||||
let resolvedColorName = colorName ?? "blue"
|
||||
let resolvedIconName = iconName ?? systemIconName(for: systemName)
|
||||
|
||||
let newSystem = ElectricalSystem(
|
||||
name: systemName,
|
||||
location: "",
|
||||
iconName: resolvedIconName,
|
||||
colorName: resolvedColorName
|
||||
)
|
||||
modelContext.insert(newSystem)
|
||||
return newSystem
|
||||
}
|
||||
|
||||
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
||||
let system = makeSystem()
|
||||
let load = createLoad(from: item, in: system)
|
||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
|
||||
}
|
||||
|
||||
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
||||
let localizedName = item.localizedName
|
||||
let baseName = localizedName.isEmpty
|
||||
? String(localized: "default.load.library", comment: "Default name when importing a library load")
|
||||
: localizedName
|
||||
let loadName = uniqueLoadName(for: system, startingWith: baseName)
|
||||
let voltage = item.displayVoltage ?? 12.0
|
||||
|
||||
let power: Double
|
||||
if let watt = item.watt {
|
||||
power = watt
|
||||
} else if let derivedCurrent = item.current, voltage > 0 {
|
||||
power = derivedCurrent * voltage
|
||||
} else {
|
||||
power = 0
|
||||
}
|
||||
|
||||
let current: Double
|
||||
if let explicitCurrent = item.current {
|
||||
current = explicitCurrent
|
||||
} else if voltage > 0 {
|
||||
current = power / voltage
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
|
||||
let newLoad = SavedLoad(
|
||||
name: loadName,
|
||||
voltage: voltage,
|
||||
current: current,
|
||||
power: power,
|
||||
length: 10.0,
|
||||
crossSection: 1.0,
|
||||
iconName: "lightbulb",
|
||||
colorName: "blue",
|
||||
isWattMode: item.watt != nil,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
)
|
||||
|
||||
modelContext.insert(newLoad)
|
||||
return newLoad
|
||||
}
|
||||
|
||||
private func uniqueLoadName(for system: ElectricalSystem, startingWith baseName: String) -> String {
|
||||
let descriptor = FetchDescriptor<SavedLoad>()
|
||||
let fetchedLoads = (try? modelContext.fetch(descriptor)) ?? []
|
||||
let existingNames = Set(fetchedLoads.filter { $0.system == system }.map { $0.name })
|
||||
|
||||
if !existingNames.contains(baseName) {
|
||||
return baseName
|
||||
}
|
||||
|
||||
var counter = 2
|
||||
var candidate = "\(baseName) \(counter)"
|
||||
|
||||
while existingNames.contains(candidate) {
|
||||
counter += 1
|
||||
candidate = "\(baseName) \(counter)"
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func deleteSystems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
let system = systems[index]
|
||||
deleteLoads(for: system)
|
||||
modelContext.delete(system)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteLoads(for system: ElectricalSystem) {
|
||||
let descriptor = FetchDescriptor<SavedLoad>()
|
||||
if let loads = try? modelContext.fetch(descriptor) {
|
||||
for load in loads where load.system == system {
|
||||
modelContext.delete(load)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loads(for system: ElectricalSystem) -> [SavedLoad] {
|
||||
allLoads.filter { $0.system == system }
|
||||
}
|
||||
|
||||
private func componentSummary(for system: ElectricalSystem) -> String {
|
||||
let systemLoads = loads(for: system)
|
||||
guard !systemLoads.isEmpty else {
|
||||
return String(localized: "system.list.no.components", comment: "Message shown when a system has no components yet")
|
||||
}
|
||||
|
||||
let count = systemLoads.count
|
||||
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
|
||||
|
||||
let formattedPower: String
|
||||
if totalPower >= 1000 {
|
||||
formattedPower = String(format: "%.1fkW", totalPower / 1000)
|
||||
} else {
|
||||
formattedPower = String(format: "%.0fW", totalPower)
|
||||
}
|
||||
|
||||
let format = NSLocalizedString(
|
||||
"system.list.component.summary",
|
||||
comment: "Summary showing number of components and the total power"
|
||||
)
|
||||
return String.localizedStringWithFormat(format, count, formattedPower)
|
||||
}
|
||||
|
||||
private func randomSystemColorName() -> String {
|
||||
systemColorOptions.randomElement() ?? "blue"
|
||||
}
|
||||
|
||||
private func systemIconName(for name: String) -> String {
|
||||
let normalized = name
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
|
||||
for mapping in systemIconMappings {
|
||||
if mapping.keywords.contains(where: { keyword in normalized.contains(keyword) }) {
|
||||
return mapping.icon
|
||||
}
|
||||
}
|
||||
|
||||
return defaultSystemIconName
|
||||
}
|
||||
|
||||
private func keywords(for localizationKey: String, fallback: [String]) -> [String] {
|
||||
let fallbackValue = fallback.joined(separator: ",")
|
||||
let localizedKeywords = NSLocalizedString(
|
||||
localizationKey,
|
||||
tableName: nil,
|
||||
bundle: .main,
|
||||
value: fallbackValue,
|
||||
comment: ""
|
||||
)
|
||||
let separators = CharacterSet(charactersIn: ",;")
|
||||
let components = localizedKeywords
|
||||
.components(separatedBy: separators)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
var uniqueKeywords: [String] = []
|
||||
|
||||
for keyword in fallback.map({ $0.lowercased() }) + components {
|
||||
if !uniqueKeywords.contains(keyword) {
|
||||
uniqueKeywords.append(keyword)
|
||||
}
|
||||
}
|
||||
|
||||
return uniqueKeywords
|
||||
}
|
||||
|
||||
private func colorForName(_ colorName: String) -> Color {
|
||||
switch colorName {
|
||||
case "blue": return .blue
|
||||
case "green": return .green
|
||||
case "orange": return .orange
|
||||
case "red": return .red
|
||||
case "purple": return .purple
|
||||
case "yellow": return .yellow
|
||||
case "pink": return .pink
|
||||
case "teal": return .teal
|
||||
case "indigo": return .indigo
|
||||
case "mint": return .mint
|
||||
case "cyan": return .cyan
|
||||
case "brown": return .brown
|
||||
case "gray": return .gray
|
||||
default: return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
128
Cable/UITestSampleData.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// UITestSampleData.swift
|
||||
// Cable
|
||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum UITestSampleData {
|
||||
static let argument = "--uitest-sample-data"
|
||||
|
||||
static func prepareIfNeeded(container: ModelContainer) {
|
||||
#if DEBUG
|
||||
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
do {
|
||||
try clearExistingData(in: context)
|
||||
try seedSampleData(in: context)
|
||||
try context.save()
|
||||
} catch {
|
||||
assertionFailure("Failed to seed UI test sample data: \(error)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private extension UITestSampleData {
|
||||
static func clearExistingData(in context: ModelContext) throws {
|
||||
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
||||
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
||||
let itemDescriptor = FetchDescriptor<Item>()
|
||||
|
||||
let systems = try context.fetch(systemDescriptor)
|
||||
let loads = try context.fetch(loadDescriptor)
|
||||
let items = try context.fetch(itemDescriptor)
|
||||
|
||||
systems.forEach { context.delete($0) }
|
||||
loads.forEach { context.delete($0) }
|
||||
items.forEach { context.delete($0) }
|
||||
}
|
||||
|
||||
static func seedSampleData(in context: ModelContext) throws {
|
||||
let adventureVan = ElectricalSystem(
|
||||
name: String(localized: "sample.system.rv.name", comment: "Sample data name for the adventure van system"),
|
||||
location: String(localized: "sample.system.rv.location", comment: "Sample data location for the adventure van system"),
|
||||
iconName: "bus",
|
||||
colorName: "orange"
|
||||
)
|
||||
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
|
||||
|
||||
let workshopBench = ElectricalSystem(
|
||||
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
|
||||
location: String(localized: "sample.system.workshop.location", comment: "Sample data location for the workshop system"),
|
||||
iconName: "wrench.adjustable",
|
||||
colorName: "teal"
|
||||
)
|
||||
workshopBench.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
|
||||
|
||||
context.insert(adventureVan)
|
||||
context.insert(workshopBench)
|
||||
|
||||
let vanFridge = SavedLoad(
|
||||
name: String(localized: "sample.load.fridge.name", comment: "Sample data load name for a compressor fridge"),
|
||||
voltage: 12.0,
|
||||
current: 4.2,
|
||||
power: 50.0,
|
||||
length: 6.0,
|
||||
crossSection: 6.0,
|
||||
iconName: "snowflake",
|
||||
colorName: "blue",
|
||||
isWattMode: true,
|
||||
system: adventureVan,
|
||||
identifier: "sample.load.fridge"
|
||||
)
|
||||
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
|
||||
|
||||
let vanLighting = SavedLoad(
|
||||
name: String(localized: "sample.load.lighting.name", comment: "Sample data load name for LED strip lighting"),
|
||||
voltage: 12.0,
|
||||
current: 2.0,
|
||||
power: 24.0,
|
||||
length: 10.0,
|
||||
crossSection: 2.5,
|
||||
iconName: "lightbulb",
|
||||
colorName: "yellow",
|
||||
isWattMode: false,
|
||||
system: adventureVan,
|
||||
identifier: "sample.load.lighting"
|
||||
)
|
||||
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
|
||||
|
||||
let workshopCompressor = SavedLoad(
|
||||
name: String(localized: "sample.load.compressor.name", comment: "Sample data load name for an air compressor"),
|
||||
voltage: 120.0,
|
||||
current: 8.0,
|
||||
power: 960.0,
|
||||
length: 15.0,
|
||||
crossSection: 16.0,
|
||||
iconName: "hammer",
|
||||
colorName: "red",
|
||||
isWattMode: true,
|
||||
system: workshopBench,
|
||||
identifier: "sample.load.compressor"
|
||||
)
|
||||
workshopCompressor.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
|
||||
|
||||
let workshopCharger = SavedLoad(
|
||||
name: String(localized: "sample.load.charger.name", comment: "Sample data load name for a tool charger"),
|
||||
voltage: 120.0,
|
||||
current: 3.5,
|
||||
power: 420.0,
|
||||
length: 8.0,
|
||||
crossSection: 10.0,
|
||||
iconName: "battery.100",
|
||||
colorName: "green",
|
||||
isWattMode: false,
|
||||
system: workshopBench,
|
||||
identifier: "sample.load.charger"
|
||||
)
|
||||
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
|
||||
|
||||
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -36,6 +36,38 @@
|
||||
"system.list.no.components" = "Noch keine Komponenten";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Metrisch (mm², m)";
|
||||
"sample.system.rv.name" = "Abenteuer-Van";
|
||||
"sample.system.rv.location" = "12V Wohnstromkreis";
|
||||
"sample.system.workshop.name" = "Werkbank";
|
||||
"sample.system.workshop.location" = "Werkzeugecke";
|
||||
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
||||
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
|
||||
"sample.load.compressor.name" = "Luftkompressor";
|
||||
"sample.load.charger.name" = "Werkzeugladegerät";
|
||||
"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus";
|
||||
"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer";
|
||||
"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot";
|
||||
"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft";
|
||||
"system.icon.keywords.ferry" = "fähre, schiff";
|
||||
"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge";
|
||||
"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage";
|
||||
"system.icon.keywords.tent" = "camp, camping, zelt, outdoor";
|
||||
"system.icon.keywords.solar" = "solar, sonne, pv";
|
||||
"system.icon.keywords.battery" = "batterie, speicher, akku";
|
||||
"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum";
|
||||
"system.icon.keywords.computer" = "computer, elektronik, labor, technik";
|
||||
"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt";
|
||||
"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt";
|
||||
"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei";
|
||||
"system.icon.keywords.light" = "licht, beleuchtung, lampe";
|
||||
"system.icon.keywords.bolt" = "strom, power, elektrisch, spannung";
|
||||
"system.icon.keywords.plug" = "stecker, netzstecker";
|
||||
"system.icon.keywords.engine" = "motor, generator, antrieb";
|
||||
"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin";
|
||||
"system.icon.keywords.water" = "wasser, pumpe, tank";
|
||||
"system.icon.keywords.heat" = "heizung, heizer, ofen";
|
||||
"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren";
|
||||
"system.icon.keywords.climate" = "klima, hvac, temperatur";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systeme";
|
||||
@@ -44,9 +76,9 @@
|
||||
"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.";
|
||||
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
|
||||
"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.";
|
||||
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Komponenten sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
|
||||
"Create Component" = "Komponente erstellen";
|
||||
"Browse Library" = "Bibliothek durchsuchen";
|
||||
"Browse" = "Durchsuchen";
|
||||
|
||||
@@ -36,6 +36,38 @@
|
||||
"system.list.no.components" = "Aún no hay componentes";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Métrico (mm², m)";
|
||||
"sample.system.rv.name" = "Furgoneta aventura";
|
||||
"sample.system.rv.location" = "Circuito de vivienda 12V";
|
||||
"sample.system.workshop.name" = "Banco de taller";
|
||||
"sample.system.workshop.location" = "Rincón de herramientas";
|
||||
"sample.load.fridge.name" = "Nevera de compresor";
|
||||
"sample.load.lighting.name" = "Iluminación LED";
|
||||
"sample.load.compressor.name" = "Compresor de aire";
|
||||
"sample.load.charger.name" = "Cargador de herramientas";
|
||||
"system.icon.keywords.rv" = "autocaravana, camper, caravana, furgo, van";
|
||||
"system.icon.keywords.truck" = "camión, remolque, tráiler";
|
||||
"system.icon.keywords.boat" = "barco, embarcación, yate, vela";
|
||||
"system.icon.keywords.plane" = "avión, vuelo, aire";
|
||||
"system.icon.keywords.ferry" = "ferry, transbordador, barco";
|
||||
"system.icon.keywords.house" = "casa, hogar, cabaña, chalet";
|
||||
"system.icon.keywords.building" = "edificio, oficina, almacén, fábrica, instalación";
|
||||
"system.icon.keywords.tent" = "camping, tienda, exterior";
|
||||
"system.icon.keywords.solar" = "solar, sol, fotovoltaico";
|
||||
"system.icon.keywords.battery" = "batería, almacenamiento, acumulador";
|
||||
"system.icon.keywords.server" = "servidor, datos, red, rack";
|
||||
"system.icon.keywords.computer" = "computadora, ordenador, electrónica, laboratorio, tecnología";
|
||||
"system.icon.keywords.gear" = "engranaje, mecánica, máquina, taller";
|
||||
"system.icon.keywords.tool" = "herramienta, mantenimiento, reparación, taller";
|
||||
"system.icon.keywords.hammer" = "martillo, carpintería";
|
||||
"system.icon.keywords.light" = "luz, iluminación, lámpara";
|
||||
"system.icon.keywords.bolt" = "volt, energía, eléctrico, potencia";
|
||||
"system.icon.keywords.plug" = "enchufe, clavija";
|
||||
"system.icon.keywords.engine" = "motor, generador";
|
||||
"system.icon.keywords.fuel" = "combustible, diésel, gasolina";
|
||||
"system.icon.keywords.water" = "agua, bomba, tanque, depósito";
|
||||
"system.icon.keywords.heat" = "calor, calefacción, horno";
|
||||
"system.icon.keywords.cold" = "frío, congelar, enfriar";
|
||||
"system.icon.keywords.climate" = "clima, hvac, temperatura";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Sistemas";
|
||||
|
||||
@@ -36,6 +36,38 @@
|
||||
"system.list.no.components" = "Aucun composant pour l'instant";
|
||||
"units.imperial.display" = "Impérial (AWG, ft)";
|
||||
"units.metric.display" = "Métrique (mm², m)";
|
||||
"sample.system.rv.name" = "Van d'aventure";
|
||||
"sample.system.rv.location" = "Circuit de vie 12 V";
|
||||
"sample.system.workshop.name" = "Établi d'atelier";
|
||||
"sample.system.workshop.location" = "Coin outils";
|
||||
"sample.load.fridge.name" = "Réfrigérateur à compresseur";
|
||||
"sample.load.lighting.name" = "Éclairage LED";
|
||||
"sample.load.compressor.name" = "Compresseur d'air";
|
||||
"sample.load.charger.name" = "Chargeur d'outils";
|
||||
"system.icon.keywords.rv" = "camping-car, van, fourgon, caravane, motorhome";
|
||||
"system.icon.keywords.truck" = "camion, remorque, poids lourd";
|
||||
"system.icon.keywords.boat" = "bateau, marine, yacht, voile";
|
||||
"system.icon.keywords.plane" = "avion, vol, air";
|
||||
"system.icon.keywords.ferry" = "ferry, traversier, bateau";
|
||||
"system.icon.keywords.house" = "maison, foyer, cabane, chalet, lodge";
|
||||
"system.icon.keywords.building" = "bâtiment, bureau, entrepôt, usine, installation";
|
||||
"system.icon.keywords.tent" = "camping, tente, plein air";
|
||||
"system.icon.keywords.solar" = "solaire, soleil";
|
||||
"system.icon.keywords.battery" = "batterie, stockage, accumulateur";
|
||||
"system.icon.keywords.server" = "serveur, données, réseau, rack";
|
||||
"system.icon.keywords.computer" = "ordinateur, électronique, labo, techno";
|
||||
"system.icon.keywords.gear" = "engrenage, mécanique, machine, atelier";
|
||||
"system.icon.keywords.tool" = "outil, maintenance, réparation, atelier";
|
||||
"system.icon.keywords.hammer" = "marteau, charpente";
|
||||
"system.icon.keywords.light" = "lumière, éclairage, lampe";
|
||||
"system.icon.keywords.bolt" = "courant, énergie, électrique";
|
||||
"system.icon.keywords.plug" = "prise, fiche";
|
||||
"system.icon.keywords.engine" = "moteur, générateur";
|
||||
"system.icon.keywords.fuel" = "carburant, diesel, essence";
|
||||
"system.icon.keywords.water" = "eau, pompe, réservoir";
|
||||
"system.icon.keywords.heat" = "chaleur, chauffage, chaudière, four";
|
||||
"system.icon.keywords.cold" = "froid, geler, refroidir";
|
||||
"system.icon.keywords.climate" = "climat, hvac, température";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systèmes";
|
||||
|
||||
@@ -36,6 +36,38 @@
|
||||
"system.list.no.components" = "Nog geen componenten";
|
||||
"units.imperial.display" = "Imperiaal (AWG, ft)";
|
||||
"units.metric.display" = "Metrisch (mm², m)";
|
||||
"sample.system.rv.name" = "Avonturenbus";
|
||||
"sample.system.rv.location" = "12V leefcircuit";
|
||||
"sample.system.workshop.name" = "Werkbank";
|
||||
"sample.system.workshop.location" = "Gereedschapshoek";
|
||||
"sample.load.fridge.name" = "Koelbox met compressor";
|
||||
"sample.load.lighting.name" = "LED-strips";
|
||||
"sample.load.compressor.name" = "Luchtcompressor";
|
||||
"sample.load.charger.name" = "Gereedschapslader";
|
||||
"system.icon.keywords.rv" = "camper, kampeerbus, buscamper, mobilhome, campervan";
|
||||
"system.icon.keywords.truck" = "vrachtwagen, trailer, aanhanger, truck";
|
||||
"system.icon.keywords.boat" = "boot, schip, jacht, zeil";
|
||||
"system.icon.keywords.plane" = "vliegtuig, vlucht, lucht";
|
||||
"system.icon.keywords.ferry" = "veerboot, ferry, schip";
|
||||
"system.icon.keywords.house" = "huis, thuis, hut, chalet, lodge";
|
||||
"system.icon.keywords.building" = "gebouw, kantoor, magazijn, fabriek, faciliteit";
|
||||
"system.icon.keywords.tent" = "kamperen, tent, buiten";
|
||||
"system.icon.keywords.solar" = "zonne, zon, zonnepaneel";
|
||||
"system.icon.keywords.battery" = "batterij, opslag, accu";
|
||||
"system.icon.keywords.server" = "server, data, netwerk, rack";
|
||||
"system.icon.keywords.computer" = "computer, elektronica, lab, tech";
|
||||
"system.icon.keywords.gear" = "tandwiel, mechanica, machine, werkplaats";
|
||||
"system.icon.keywords.tool" = "gereedschap, onderhoud, reparatie, werkplaats";
|
||||
"system.icon.keywords.hammer" = "hamer, timmerwerk";
|
||||
"system.icon.keywords.light" = "licht, verlichting, lamp";
|
||||
"system.icon.keywords.bolt" = "stroom, kracht, elektrisch, spanning";
|
||||
"system.icon.keywords.plug" = "stekker, aansluiting";
|
||||
"system.icon.keywords.engine" = "motor, generator";
|
||||
"system.icon.keywords.fuel" = "brandstof, diesel, benzine";
|
||||
"system.icon.keywords.water" = "water, pomp, tank, reservoir";
|
||||
"system.icon.keywords.heat" = "warmte, verwarming, kachel";
|
||||
"system.icon.keywords.cold" = "koud, vries, koel";
|
||||
"system.icon.keywords.climate" = "klimaat, hvac, temperatuur";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systemen";
|
||||
|
||||
104
CableTests/ComponentLibraryItemTests.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import Testing
|
||||
@testable import Cable
|
||||
|
||||
struct ComponentLibraryItemTests {
|
||||
|
||||
@Test func localizedNameUsesExactLocaleMatch() async throws {
|
||||
let item = ComponentLibraryItem(
|
||||
id: "component-1",
|
||||
name: "Anchor Winch",
|
||||
translations: ["de_DE": "Ankerwinde"],
|
||||
voltageIn: nil,
|
||||
voltageOut: nil,
|
||||
watt: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let german = Locale(identifier: "de_DE")
|
||||
#expect(item.localizedName(for: german) == "Ankerwinde")
|
||||
}
|
||||
|
||||
@Test func localizedNameFallsBackToBaseName() async throws {
|
||||
let item = ComponentLibraryItem(
|
||||
id: "component-2",
|
||||
name: "Anchor Winch",
|
||||
translations: [:],
|
||||
voltageIn: nil,
|
||||
voltageOut: nil,
|
||||
watt: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let french = Locale(identifier: "fr_FR")
|
||||
#expect(item.localizedName(for: french) == "Anchor Winch")
|
||||
}
|
||||
|
||||
@Test func localizedNameDoesNotFallbackToSecondaryLanguages() async throws {
|
||||
let item = ComponentLibraryItem(
|
||||
id: "component-5",
|
||||
name: "Anchor Winch",
|
||||
translations: [
|
||||
"es_ES": "Molinete",
|
||||
"de_DE": "Ankerwinde"
|
||||
],
|
||||
voltageIn: nil,
|
||||
voltageOut: nil,
|
||||
watt: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let languages = ["fr-FR", "de-DE", "es-ES"]
|
||||
#expect(item.localizedName(usingPreferredLanguages: languages) == nil)
|
||||
}
|
||||
|
||||
@Test func localizedNameUsesLanguageOnlyMatch() async throws {
|
||||
let item = ComponentLibraryItem(
|
||||
id: "component-3",
|
||||
name: "Anchor Winch",
|
||||
translations: ["es": "Molinete"],
|
||||
voltageIn: nil,
|
||||
voltageOut: nil,
|
||||
watt: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let spanishMexico = Locale(identifier: "es_MX")
|
||||
#expect(item.localizedName(for: spanishMexico) == "Molinete")
|
||||
}
|
||||
|
||||
@Test func localizedNameFallsBackToMatchingLanguageFromRegionalEntry() async throws {
|
||||
let item = ComponentLibraryItem(
|
||||
id: "component-6",
|
||||
name: "Anchor Winch",
|
||||
translations: ["de_DE": "Ankerwinde"],
|
||||
voltageIn: nil,
|
||||
voltageOut: nil,
|
||||
watt: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let germanSwitzerland = Locale(identifier: "de_CH")
|
||||
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
|
||||
}
|
||||
|
||||
@Test func localizedNameHandlesHyphenatedKeys() async throws {
|
||||
let item = ComponentLibraryItem(
|
||||
id: "component-4",
|
||||
name: "Anchor Winch",
|
||||
translations: ["fr-FR": "Guindeau"],
|
||||
voltageIn: nil,
|
||||
voltageOut: nil,
|
||||
watt: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let french = Locale(identifier: "fr_FR")
|
||||
#expect(item.localizedName(for: french) == "Guindeau")
|
||||
}
|
||||
}
|
||||
@@ -8,34 +8,90 @@
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshot: XCTestCase {
|
||||
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||
|
||||
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.
|
||||
try super.setUpWithError()
|
||||
ensureDoNotDisturbEnabled()
|
||||
dismissSystemOverlays()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
try super.tearDownWithError()
|
||||
dismissSystemOverlays()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
dismissSystemOverlays()
|
||||
}
|
||||
|
||||
// @MainActor
|
||||
// func testLaunchPerformance() throws {
|
||||
// // This measures how long it takes to launch your application.
|
||||
// measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
// XCUIApplication().launch()
|
||||
// }
|
||||
// }
|
||||
private func ensureDoNotDisturbEnabled() {
|
||||
springboard.activate()
|
||||
let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02))
|
||||
let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30))
|
||||
pullStart.press(forDuration: 0.1, thenDragTo: pullEnd)
|
||||
|
||||
let focusTile = springboard.otherElements["Focus"]
|
||||
let focusButton = springboard.buttons["Focus"]
|
||||
if focusTile.waitForExistence(timeout: 2) {
|
||||
focusTile.press(forDuration: 1.0)
|
||||
} else if focusButton.waitForExistence(timeout: 2) {
|
||||
focusButton.press(forDuration: 1.0)
|
||||
}
|
||||
|
||||
let dndButton = springboard.buttons["Do Not Disturb"]
|
||||
if dndButton.waitForExistence(timeout: 1) {
|
||||
if !dndButton.isSelected {
|
||||
dndButton.tap()
|
||||
}
|
||||
} else {
|
||||
let dndCell = springboard.cells["Do Not Disturb"]
|
||||
if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected {
|
||||
dndCell.tap()
|
||||
} else {
|
||||
let dndLabel = springboard.staticTexts["Do Not Disturb"]
|
||||
if dndLabel.waitForExistence(timeout: 1) {
|
||||
dndLabel.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85))
|
||||
let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15))
|
||||
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
|
||||
}
|
||||
|
||||
private func dismissSystemOverlays() {
|
||||
let app = XCUIApplication()
|
||||
let alertButtons = [
|
||||
"OK", "Allow", "Later", "Not Now", "Close",
|
||||
"Continue", "Remind Me Later", "Maybe Later",
|
||||
]
|
||||
|
||||
if app.alerts.firstMatch.exists {
|
||||
handleAlerts(in: app, buttons: alertButtons)
|
||||
}
|
||||
|
||||
if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists {
|
||||
handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"])
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAlerts(in application: XCUIApplication, buttons: [String]) {
|
||||
for buttonLabel in buttons {
|
||||
let button = application.buttons[buttonLabel]
|
||||
if button.waitForExistence(timeout: 0.5) {
|
||||
button.tap()
|
||||
return
|
||||
}
|
||||
}
|
||||
let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch
|
||||
if closeButton.exists {
|
||||
closeButton.tap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,44 +8,99 @@
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||
|
||||
|
||||
|
||||
private func takeScreenshot(name: String,
|
||||
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = lifetime
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func 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")
|
||||
takeScreenshot(name: "01-OnboardingSystemsView")
|
||||
|
||||
let createSystemButton = app.buttons["create-system-button"]
|
||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||
createSystemButton.tap()
|
||||
|
||||
snapshot("1OnboardingLoadsView")
|
||||
takeScreenshot(name: "02-OnboardingLoadsView")
|
||||
|
||||
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||
let selectComponentButton = app.buttons["select-component-button"]
|
||||
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
|
||||
selectComponentButton.tap()
|
||||
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
||||
Thread.sleep(forTimeInterval: 10)
|
||||
takeScreenshot(name: "04-ComponentSelectorView")
|
||||
libraryCloseButton.tap()
|
||||
|
||||
let createComponentButton = app.buttons["create-component-button"]
|
||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||
createComponentButton.tap()
|
||||
snapshot("2LoadEditorView")
|
||||
takeScreenshot(name: "03-LoadEditorView")
|
||||
}
|
||||
|
||||
func testWithSampleData() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments.append("--uitest-sample-data")
|
||||
app.launch()
|
||||
|
||||
let systemsCollection = app.collectionViews.firstMatch
|
||||
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
|
||||
|
||||
let systemsList: XCUIElement
|
||||
if collectionExists {
|
||||
systemsList = systemsCollection
|
||||
} else {
|
||||
let table = app.tables.firstMatch
|
||||
XCTAssertTrue(table.waitForExistence(timeout: 3))
|
||||
systemsList = table
|
||||
}
|
||||
|
||||
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
||||
|
||||
takeScreenshot(name: "05-SystemsWithSampleData")
|
||||
|
||||
firstSystemCell.tap()
|
||||
|
||||
let loadsCollection = app.collectionViews["loads-list"]
|
||||
let loadsTable = app.tables["loads-list"]
|
||||
|
||||
let loadsElement: XCUIElement
|
||||
if loadsCollection.waitForExistence(timeout: 3) {
|
||||
loadsElement = loadsCollection
|
||||
} else {
|
||||
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
|
||||
loadsElement = loadsTable
|
||||
}
|
||||
|
||||
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
takeScreenshot(name: "06-AdventureVanLoads")
|
||||
|
||||
let bomButton = app.buttons["system-bom-button"]
|
||||
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
||||
bomButton.tap()
|
||||
|
||||
let bomView = app.otherElements["system-bom-view"]
|
||||
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
//
|
||||
// Created by Felix Krause on 10/8/15.
|
||||
//
|
||||
|
||||
// -----------------------------------------------------
|
||||
// IMPORTANT: When modifying this file, make sure to
|
||||
// increment the version number at the very
|
||||
// bottom of the file to notify users about
|
||||
// the new SnapshotHelper.swift
|
||||
// -----------------------------------------------------
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
} else {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
case cannotFindSimulatorHomeDirectory
|
||||
case cannotRunOnPhysicalDevice
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .cannotFindSimulatorHomeDirectory:
|
||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
||||
case .cannotRunOnPhysicalDevice:
|
||||
return "Can't use Snapshot on a physical device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
static var cacheDirectory: URL?
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
Snapshot.app = app
|
||||
Snapshot.waitForAnimations = waitForAnimations
|
||||
|
||||
do {
|
||||
let cacheDir = try getCacheDirectory()
|
||||
Snapshot.cacheDirectory = cacheDir
|
||||
setLanguage(app)
|
||||
setLocale(app)
|
||||
setLaunchArguments(app)
|
||||
} catch let error {
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
class func setLanguage(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set language...")
|
||||
}
|
||||
}
|
||||
|
||||
class func setLocale(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
||||
|
||||
do {
|
||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
||||
let results = matches.map { result -> String in
|
||||
(launchArguments as NSString).substring(with: result.range)
|
||||
}
|
||||
app.launchArguments += results
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set launch_arguments...")
|
||||
}
|
||||
}
|
||||
|
||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
if timeout > 0 {
|
||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
||||
}
|
||||
|
||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
||||
|
||||
if Snapshot.waitForAnimations {
|
||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
||||
#else
|
||||
|
||||
guard self.app != nil else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
#endif
|
||||
|
||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
||||
|
||||
do {
|
||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
||||
let range = NSRange(location: 0, length: simulator.count)
|
||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
} catch let error {
|
||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
||||
#if os(watchOS)
|
||||
return image
|
||||
#else
|
||||
if #available(iOS 10.0, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
||||
}
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#endif
|
||||
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
||||
}
|
||||
|
||||
class func getCacheDirectory() throws -> URL {
|
||||
let cachePath = "Library/Caches/tools.fastlane"
|
||||
// on OSX config is stored in /Users/<username>/Library
|
||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
||||
#if os(OSX)
|
||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
||||
}
|
||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#else
|
||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementAttributes {
|
||||
var isNetworkLoadingIndicator: Bool {
|
||||
if hasAllowListedIdentifier { return false }
|
||||
|
||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
||||
|
||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
||||
}
|
||||
|
||||
var hasAllowListedIdentifier: Bool {
|
||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
||||
|
||||
return allowListedIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
||||
if elementType == .statusBar { return true }
|
||||
guard frame.origin == .zero else { return false }
|
||||
|
||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
||||
|
||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementQuery {
|
||||
var networkLoadingIndicators: XCUIElementQuery {
|
||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isNetworkLoadingIndicator
|
||||
}
|
||||
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
}
|
||||
|
||||
let deviceWidth = app.windows.firstMatch.frame.width
|
||||
|
||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isStatusBar(deviceWidth)
|
||||
}
|
||||
|
||||
return self.containing(isStatusBar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
||||
return numberA...numberB ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.30]
|
||||
@@ -1,6 +0,0 @@
|
||||
app_identifier("app.voltplan.CableApp") # The bundle identifier of your app
|
||||
# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username
|
||||
|
||||
|
||||
# For more information about the Appfile, see:
|
||||
# https://docs.fastlane.tools/advanced/#appfile
|
||||
@@ -1,23 +0,0 @@
|
||||
# This file contains the fastlane.tools configuration
|
||||
# You can find the documentation at https://docs.fastlane.tools
|
||||
#
|
||||
# For a list of all available actions, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/actions
|
||||
#
|
||||
# For a list of all available plugins, check out
|
||||
#
|
||||
# https://docs.fastlane.tools/plugins/available-plugins
|
||||
#
|
||||
|
||||
# Uncomment the line if you want fastlane to automatically update itself
|
||||
# update_fastlane
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Generate new localized screenshots"
|
||||
lane :screenshots do
|
||||
capture_screenshots(scheme: "CableScreenshots")
|
||||
end
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
fastlane documentation
|
||||
----
|
||||
|
||||
# Installation
|
||||
|
||||
Make sure you have the latest version of the Xcode command line tools installed:
|
||||
|
||||
```sh
|
||||
xcode-select --install
|
||||
```
|
||||
|
||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||
|
||||
# Available Actions
|
||||
|
||||
## iOS
|
||||
|
||||
### ios screenshots
|
||||
|
||||
```sh
|
||||
[bundle exec] fastlane ios screenshots
|
||||
```
|
||||
|
||||
Generate new localized screenshots
|
||||
|
||||
----
|
||||
|
||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||
|
||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||
|
||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
||||
@@ -1,54 +0,0 @@
|
||||
# Uncomment the lines below you want to change by removing the # in the beginning
|
||||
devices([
|
||||
"iPhone 17 Pro",
|
||||
"iPhone 17 Pro Max"
|
||||
])
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"nl-NL",
|
||||
"es-ES"
|
||||
])
|
||||
|
||||
scheme("CableScreenshots")
|
||||
clear_previous_screenshots(true)
|
||||
localize_simulator(true)
|
||||
erase_simulator(true)
|
||||
override_status_bar(true)
|
||||
# A list of devices you want to take the screenshots from
|
||||
# devices([
|
||||
# "iPhone 8",
|
||||
# "iPhone 8 Plus",
|
||||
# "iPhone SE",
|
||||
# "iPhone X",
|
||||
# "iPad Pro (12.9-inch)",
|
||||
# "iPad Pro (9.7-inch)",
|
||||
# "Apple TV 1080p",
|
||||
# "Apple Watch Series 6 - 44mm"
|
||||
# ])
|
||||
|
||||
# languages([
|
||||
# "en-US",
|
||||
# "de-DE",
|
||||
# "it-IT",
|
||||
# ["pt", "pt_BR"] # Portuguese with Brazilian locale
|
||||
# ])
|
||||
|
||||
# The name of the scheme which contains the UI Tests
|
||||
# scheme("SchemeName")
|
||||
|
||||
# Where should the resulting screenshots be stored?
|
||||
# output_directory("./screenshots")
|
||||
|
||||
# remove the '#' to clear all previously generated screenshots before creating new ones
|
||||
# clear_previous_screenshots(true)
|
||||
|
||||
# Remove the '#' to set the status bar to 9:41 AM, and show full battery and reception. See also override_status_bar_arguments for custom options.
|
||||
# override_status_bar(true)
|
||||
|
||||
# Arguments to pass to the app on launch. See https://docs.fastlane.tools/actions/snapshot/#launch-arguments
|
||||
# launch_arguments(["-favColor red"])
|
||||
|
||||
# For more information about all available options run
|
||||
# fastlane action snapshot
|
||||
@@ -1,313 +0,0 @@
|
||||
//
|
||||
// SnapshotHelper.swift
|
||||
// Example
|
||||
//
|
||||
// Created by Felix Krause on 10/8/15.
|
||||
//
|
||||
|
||||
// -----------------------------------------------------
|
||||
// IMPORTANT: When modifying this file, make sure to
|
||||
// increment the version number at the very
|
||||
// bottom of the file to notify users about
|
||||
// the new SnapshotHelper.swift
|
||||
// -----------------------------------------------------
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
} else {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
|
||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
case cannotFindSimulatorHomeDirectory
|
||||
case cannotRunOnPhysicalDevice
|
||||
|
||||
var debugDescription: String {
|
||||
switch self {
|
||||
case .cannotFindSimulatorHomeDirectory:
|
||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
||||
case .cannotRunOnPhysicalDevice:
|
||||
return "Can't use Snapshot on a physical device."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
static var cacheDirectory: URL?
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
Snapshot.app = app
|
||||
Snapshot.waitForAnimations = waitForAnimations
|
||||
|
||||
do {
|
||||
let cacheDir = try getCacheDirectory()
|
||||
Snapshot.cacheDirectory = cacheDir
|
||||
setLanguage(app)
|
||||
setLocale(app)
|
||||
setLaunchArguments(app)
|
||||
} catch let error {
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
class func setLanguage(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set language...")
|
||||
}
|
||||
}
|
||||
|
||||
class func setLocale(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
||||
guard let cacheDirectory = self.cacheDirectory else {
|
||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
||||
return
|
||||
}
|
||||
|
||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
||||
|
||||
do {
|
||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
||||
let results = matches.map { result -> String in
|
||||
(launchArguments as NSString).substring(with: result.range)
|
||||
}
|
||||
app.launchArguments += results
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set launch_arguments...")
|
||||
}
|
||||
}
|
||||
|
||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
if timeout > 0 {
|
||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
||||
}
|
||||
|
||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
||||
|
||||
if Snapshot.waitForAnimations {
|
||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
||||
}
|
||||
|
||||
#if os(OSX)
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
||||
#else
|
||||
|
||||
guard self.app != nil else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
#endif
|
||||
|
||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
||||
|
||||
do {
|
||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
||||
let range = NSRange(location: 0, length: simulator.count)
|
||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
} catch let error {
|
||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
||||
NSLog(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
||||
#if os(watchOS)
|
||||
return image
|
||||
#else
|
||||
if #available(iOS 10.0, *) {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
||||
}
|
||||
} else {
|
||||
return image
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
||||
#if os(tvOS)
|
||||
return
|
||||
#endif
|
||||
|
||||
guard let app = self.app else {
|
||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
return
|
||||
}
|
||||
|
||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
||||
}
|
||||
|
||||
class func getCacheDirectory() throws -> URL {
|
||||
let cachePath = "Library/Caches/tools.fastlane"
|
||||
// on OSX config is stored in /Users/<username>/Library
|
||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
||||
#if os(OSX)
|
||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
||||
}
|
||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
||||
return homeDir.appendingPathComponent(cachePath)
|
||||
#else
|
||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementAttributes {
|
||||
var isNetworkLoadingIndicator: Bool {
|
||||
if hasAllowListedIdentifier { return false }
|
||||
|
||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
||||
|
||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
||||
}
|
||||
|
||||
var hasAllowListedIdentifier: Bool {
|
||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
||||
|
||||
return allowListedIdentifiers.contains(identifier)
|
||||
}
|
||||
|
||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
||||
if elementType == .statusBar { return true }
|
||||
guard frame.origin == .zero else { return false }
|
||||
|
||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
||||
|
||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
||||
}
|
||||
}
|
||||
|
||||
private extension XCUIElementQuery {
|
||||
var networkLoadingIndicators: XCUIElementQuery {
|
||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isNetworkLoadingIndicator
|
||||
}
|
||||
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
}
|
||||
|
||||
let deviceWidth = app.windows.firstMatch.frame.width
|
||||
|
||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
||||
|
||||
return element.isStatusBar(deviceWidth)
|
||||
}
|
||||
|
||||
return self.containing(isStatusBar)
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGFloat {
|
||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
||||
return numberA...numberB ~= self
|
||||
}
|
||||
}
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.30]
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites>
|
||||
<testsuite name="fastlane.lanes">
|
||||
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000145">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: capture_screenshots" time="392.968167">
|
||||
|
||||
</testcase>
|
||||
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
407
frame_screens.sh
Executable file
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
FONT_COLOR="#3C3C3C" # color for light text
|
||||
FONT_BOLD_COLOR="#B51700" # color for bold text
|
||||
|
||||
ONLY_IPHONE=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
|
||||
|
||||
--iphone-only Only frame screenshots whose device slug is not iPad.
|
||||
SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots)
|
||||
BG_IMAGE Background image to use (default: Shots/frame-bg.png)
|
||||
OUT_ROOT Output folder for framed shots (default: Shots/Framed)
|
||||
EOF
|
||||
}
|
||||
|
||||
POSITIONAL_ARGS=()
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--iphone-only)
|
||||
ONLY_IPHONE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
POSITIONAL_ARGS+=("$@")
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
POSITIONAL_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ((${#POSITIONAL_ARGS[@]})); then
|
||||
set -- "${POSITIONAL_ARGS[@]}"
|
||||
else
|
||||
set --
|
||||
fi
|
||||
|
||||
# Inputs
|
||||
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…)
|
||||
BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image
|
||||
OUT_ROOT="${3:-Shots/Framed}" # output folder
|
||||
FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text
|
||||
FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
|
||||
|
||||
# Tweakables
|
||||
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
|
||||
INSET=2 # inset (px) to shave off simulator’s black edge pixels
|
||||
SHADOW_OPACITY=0 # 0–100
|
||||
SHADOW_BLUR=20 # blur radius
|
||||
SHADOW_OFFSET_X=0 # px
|
||||
SHADOW_OFFSET_Y=40 # px
|
||||
CANVAS_MARGIN=245 # default margin around the device on the background, px
|
||||
TITLE_MARGIN=378 # default margin above the device for title text, px
|
||||
TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px
|
||||
|
||||
# Device-specific overrides (can be tuned via env vars)
|
||||
TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}"
|
||||
TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}"
|
||||
TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}"
|
||||
TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}"
|
||||
IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}"
|
||||
IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}"
|
||||
IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}"
|
||||
|
||||
mkdir -p "$OUT_ROOT"
|
||||
|
||||
# Function to render mixed-font text (light + semi-bold for *text*)
|
||||
render_mixed_font_title() {
|
||||
local canvas="$1"
|
||||
local title_text="$2"
|
||||
local title_y="$3"
|
||||
local output="$4"
|
||||
|
||||
local expanded_title
|
||||
expanded_title="$(printf '%b' "$title_text")"
|
||||
|
||||
local temp_img
|
||||
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
|
||||
cp "$canvas" "$temp_img"
|
||||
|
||||
read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")"
|
||||
|
||||
local -a lines=()
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
lines+=("$line")
|
||||
done < <(printf '%s' "$expanded_title")
|
||||
|
||||
if ((${#lines[@]} == 0)); then
|
||||
lines+=("$expanded_title")
|
||||
fi
|
||||
|
||||
if ((${#lines[@]} > 2)); then
|
||||
lines=("${lines[@]:0:2}")
|
||||
fi
|
||||
|
||||
for idx in "${!lines[@]}"; do
|
||||
local line="${lines[$idx]}"
|
||||
local current_y=$((title_y + idx * TITLE_LINE_SPACING))
|
||||
|
||||
local -a text_segments=()
|
||||
local -a font_types=()
|
||||
local current_text=""
|
||||
local in_bold=false
|
||||
local i=0
|
||||
local line_length=${#line}
|
||||
|
||||
while [ $i -lt $line_length ]; do
|
||||
local char="${line:$i:1}"
|
||||
if [[ "$char" == "*" ]]; then
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
else
|
||||
font_types+=("light")
|
||||
fi
|
||||
current_text=""
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
in_bold=false
|
||||
else
|
||||
in_bold=true
|
||||
fi
|
||||
else
|
||||
current_text+="$char"
|
||||
fi
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
else
|
||||
font_types+=("light")
|
||||
fi
|
||||
|
||||
local total_width=0
|
||||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||||
local segment="${text_segments[$j]}"
|
||||
if [[ -n "$segment" ]]; then
|
||||
local font_for_measurement="$FONT"
|
||||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||||
font_for_measurement="$FONT_BOLD"
|
||||
fi
|
||||
local segment_for_measurement="$segment"
|
||||
segment_for_measurement="${segment_for_measurement/#/ }"
|
||||
segment_for_measurement="${segment_for_measurement/%/ }"
|
||||
local part_width
|
||||
part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
|
||||
total_width=$((total_width + part_width))
|
||||
fi
|
||||
done
|
||||
|
||||
if (( total_width <= 0 )); then
|
||||
continue
|
||||
fi
|
||||
|
||||
local start_x=$(( (canvas_w - total_width) / 2 ))
|
||||
local x_offset=0
|
||||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||||
local segment="${text_segments[$j]}"
|
||||
if [[ -n "$segment" ]]; then
|
||||
local font_to_use="$FONT"
|
||||
local color_to_use="$FONT_COLOR"
|
||||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||||
font_to_use="$FONT_BOLD"
|
||||
color_to_use="$FONT_BOLD_COLOR"
|
||||
fi
|
||||
local segment_for_rendering="$segment"
|
||||
segment_for_rendering="${segment_for_rendering/#/ }"
|
||||
segment_for_rendering="${segment_for_rendering/%/ }"
|
||||
magick "$temp_img" \
|
||||
-font "$font_to_use" -pointsize 148 -fill "$color_to_use" \
|
||||
-gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \
|
||||
"$temp_img"
|
||||
local text_width
|
||||
text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
|
||||
x_offset=$((x_offset + text_width))
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
cp "$temp_img" "$output"
|
||||
rm -f "$temp_img"
|
||||
}
|
||||
|
||||
# Function to get title from config file
|
||||
get_title() {
|
||||
local lang="$1"
|
||||
local screenshot_name="$2"
|
||||
local config_file="./Shots/Titles/${lang}.conf"
|
||||
|
||||
# Extract view name from filename format: 03-LoadEditorView_0_5EE662BD-84C7-41AC-806E-EB8C7340A037.png
|
||||
# Remove .png extension, then extract the part after the first dash and before the first underscore
|
||||
local base_name=$(basename "$screenshot_name" .png)
|
||||
# Remove leading number and dash (e.g., "03-")
|
||||
base_name=${base_name#*-}
|
||||
# Remove everything from the first underscore onwards (e.g., "_0_5EE662BD...")
|
||||
base_name=${base_name%%_*}
|
||||
|
||||
# Try to find title in config file
|
||||
if [[ -f "$config_file" ]]; then
|
||||
local title=$(grep "^${base_name}=" "$config_file" 2>/dev/null | cut -d'=' -f2-)
|
||||
if [[ -n "$title" ]]; then
|
||||
echo "$title"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to default title
|
||||
echo "***NOT SET***"
|
||||
}
|
||||
|
||||
# Function to frame one screenshot
|
||||
frame_one () {
|
||||
local in="$1" # input screenshot (e.g., 1320x2868)
|
||||
local out="$2" # output image
|
||||
local bg="$3"
|
||||
local lang="$4" # language code (e.g., "de", "en")
|
||||
local screenshot_name="$5" # screenshot filename
|
||||
local target_width="$6"
|
||||
local target_height="$7"
|
||||
local canvas_margin="$8"
|
||||
local title_margin="$9"
|
||||
|
||||
# Read sizes
|
||||
read -r W H <<<"$(identify -format "%w %h" "$in")"
|
||||
|
||||
# Determine corner radius
|
||||
local R
|
||||
if [[ "$CORNER_RADIUS" == "auto" ]]; then
|
||||
# Heuristic: ~1/12 of width works well for iPhone 6.9" (≈110px for 1320px width)
|
||||
R=$(( W / 12 ))
|
||||
else
|
||||
R=$CORNER_RADIUS
|
||||
fi
|
||||
|
||||
# Create rounded-corner mask the same size as the screenshot
|
||||
local mask
|
||||
mask="$(mktemp /tmp/mask.XXXXXX_$$.png)"
|
||||
magick -size "${W}x${H}" xc:black \
|
||||
-fill white -draw "roundrectangle ${INSET},${INSET},$((W-1-INSET)),$((H-1-INSET)),$R,$R" \
|
||||
"$mask"
|
||||
|
||||
# Apply rounded corners + make a soft drop shadow
|
||||
# 1) Rounded PNG
|
||||
local rounded
|
||||
rounded="$(mktemp /tmp/rounded.XXXXXX_$$.png)"
|
||||
magick "$in" -alpha set "$mask" -compose copyopacity -composite "$rounded"
|
||||
|
||||
# 2) Shadow from rounded image
|
||||
local shadow
|
||||
shadow="$(mktemp /tmp/shadow.XXXXXX_$$.png)"
|
||||
magick "$rounded" \
|
||||
\( +clone -background black -shadow ${SHADOW_OPACITY}x${SHADOW_BLUR}+${SHADOW_OFFSET_X}+${SHADOW_OFFSET_Y} \) \
|
||||
+swap -background none -layers merge +repage "$shadow"
|
||||
|
||||
# Compose on the background, centered
|
||||
# First, scale background to be at least screenshot+margin in both dimensions
|
||||
read -r BW BH <<<"$(identify -format "%w %h" "$bg")"
|
||||
local minW=$((W + 2*canvas_margin))
|
||||
local minH=$((H + 2*canvas_margin + title_margin))
|
||||
local canvas
|
||||
canvas="$(mktemp /tmp/canvas.XXXXXX_$$.png)"
|
||||
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
|
||||
|
||||
# Add title text above the screenshot
|
||||
local title_text=$(get_title "$lang" "$screenshot_name")
|
||||
local with_title
|
||||
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
|
||||
|
||||
# Calculate title position (center horizontally, positioned above the screenshot)
|
||||
local title_y=$((title_margin - 100)) # 10px from top of title margin
|
||||
|
||||
# Render title with mixed fonts
|
||||
render_mixed_font_title "$canvas" "$title_text" "$title_y" "$with_title"
|
||||
|
||||
# Now place shadow (which already includes the rounded image) positioned below the title
|
||||
# Calculate the vertical offset to center the screenshot in the remaining space below the title
|
||||
local screenshot_offset=$((title_margin*2))
|
||||
local temp_result
|
||||
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
|
||||
magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result"
|
||||
|
||||
# Final step: scale to exact dimensions 1320 × 2868px
|
||||
magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out"
|
||||
|
||||
rm -f "$mask" "$rounded" "$shadow" "$canvas" "$with_title" "$temp_result"
|
||||
}
|
||||
|
||||
# Process all screenshots in SRC_ROOT/*/*.png
|
||||
resolve_device_profile() {
|
||||
local device_slug="$1"
|
||||
|
||||
PROFILE_BG="$BG_IMAGE"
|
||||
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_PHONE"
|
||||
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE"
|
||||
PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN"
|
||||
PROFILE_TITLE_MARGIN="$TITLE_MARGIN"
|
||||
|
||||
if [[ -n "$device_slug" ]]; then
|
||||
local slug_lower
|
||||
slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$slug_lower" == *"ipad"* ]]; then
|
||||
PROFILE_BG="$IPAD_BG_IMAGE"
|
||||
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_IPAD"
|
||||
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD"
|
||||
PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN"
|
||||
PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
process_lang_dir() {
|
||||
local lang_path="$1"
|
||||
local lang="$2"
|
||||
local device_slug="$3"
|
||||
|
||||
local out_dir="$OUT_ROOT"
|
||||
local log_prefix="$lang"
|
||||
|
||||
if [[ -n "$device_slug" ]]; then
|
||||
out_dir="$out_dir/$device_slug"
|
||||
log_prefix="$device_slug/$lang"
|
||||
fi
|
||||
|
||||
out_dir="$out_dir/$lang"
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
resolve_device_profile "$device_slug"
|
||||
|
||||
shopt -s nullglob
|
||||
for shot in "$lang_path"/*.png; do
|
||||
local base="$(basename "$shot")"
|
||||
frame_one \
|
||||
"$shot" \
|
||||
"$out_dir/$base" \
|
||||
"$PROFILE_BG" \
|
||||
"$lang" \
|
||||
"$base" \
|
||||
"$PROFILE_TARGET_WIDTH" \
|
||||
"$PROFILE_TARGET_HEIGHT" \
|
||||
"$PROFILE_CANVAS_MARGIN" \
|
||||
"$PROFILE_TITLE_MARGIN"
|
||||
echo "Framed: $log_prefix/$base"
|
||||
done
|
||||
}
|
||||
|
||||
shopt -s nullglob
|
||||
found_any=false
|
||||
skipped_for_device=false
|
||||
for entry in "$SRC_ROOT"/*; do
|
||||
[[ -d "$entry" ]] || continue
|
||||
entry_basename="$(basename "$entry")"
|
||||
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then
|
||||
skipped_for_device=true
|
||||
continue
|
||||
fi
|
||||
|
||||
pattern="${entry%/}/*.png"
|
||||
if compgen -G "$pattern" > /dev/null; then
|
||||
process_lang_dir "$entry" "$(basename "$entry")" ""
|
||||
found_any=true
|
||||
continue
|
||||
fi
|
||||
|
||||
for langdir in "$entry"/*; do
|
||||
[[ -d "$langdir" ]] || continue
|
||||
if [[ "$ONLY_IPHONE" == true ]]; then
|
||||
lang_device_slug="$(basename "$entry")"
|
||||
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$lang_slug_lower" == *"ipad"* ]]; then
|
||||
skipped_for_device=true
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
pattern="${langdir%/}/*.png"
|
||||
if compgen -G "$pattern" > /dev/null; then
|
||||
process_lang_dir "$langdir" "$(basename "$langdir")" "$(basename "$entry")"
|
||||
found_any=true
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [[ "$found_any" == false ]]; then
|
||||
if [[ "$ONLY_IPHONE" == true && "$skipped_for_device" == true ]]; then
|
||||
echo "No iPhone screenshots found under $SRC_ROOT" >&2
|
||||
else
|
||||
echo "No screenshots found under $SRC_ROOT" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Done. Framed images in: $OUT_ROOT/"
|
||||
101
shooter.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCHEME="CableScreenshots"
|
||||
RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
|
||||
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
|
||||
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
|
||||
|
||||
is_truthy() {
|
||||
case "$1" in
|
||||
1|true|TRUE|yes|YES|on|ON) return 0 ;;
|
||||
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
DEVICE_MATRIX=(
|
||||
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
|
||||
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
|
||||
)
|
||||
|
||||
command -v xcparse >/dev/null 2>&1 || {
|
||||
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
|
||||
resolve_udid() {
|
||||
local name="$1"; local os="$2"
|
||||
if [[ -n "$os" ]]; then
|
||||
# Prefer Shutdown state for a clean start
|
||||
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
|
||||
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
|
||||
else
|
||||
xcrun simctl list devices | awk -v n="$name" -F '[()]' \
|
||||
'$0 ~ n && /Shutdown/ {print $2; exit}'
|
||||
fi
|
||||
}
|
||||
|
||||
for device_entry in "${DEVICE_MATRIX[@]}"; do
|
||||
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
|
||||
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
|
||||
|
||||
for lang in de fr en es nl; do
|
||||
echo "Resetting simulator for a clean start..."
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
fi
|
||||
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
if is_truthy "$RESET_SIMULATOR"; then
|
||||
xcrun simctl erase "$UDID"
|
||||
else
|
||||
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
|
||||
if [[ -n "$bundle" ]]; then
|
||||
xcrun simctl terminate "$UDID" "$bundle" || true
|
||||
xcrun simctl uninstall "$UDID" "$bundle" || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo "Running screenshots for $lang"
|
||||
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
|
||||
|
||||
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
|
||||
fi
|
||||
|
||||
xcrun simctl boot "$UDID" || true
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
|
||||
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
xcrun simctl boot "$UDID"
|
||||
xcrun simctl status_bar booted override \
|
||||
--time "9:41" \
|
||||
--batteryState charged --batteryLevel 100 \
|
||||
--wifiBars 3
|
||||
|
||||
|
||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
||||
rm -rf "$bundle" "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
|
||||
xcodebuild test \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$UDID" \
|
||||
-resultBundlePath "$bundle"
|
||||
|
||||
xcparse screenshots "$bundle" "$outdir"
|
||||
echo "Exported screenshots to $outdir"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
done
|
||||
done
|
||||