better presentation fot the App Store
@@ -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
|
||||
|
||||
@@ -43,3 +43,27 @@
|
||||
"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";
|
||||
|
||||
@@ -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 {
|
||||
@@ -396,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)
|
||||
}
|
||||
@@ -414,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -426,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
|
||||
|
||||
@@ -292,7 +292,8 @@ struct LoadsView: View {
|
||||
}
|
||||
|
||||
private func addComponent(_ item: ComponentLibraryItem) {
|
||||
let baseName = item.name.isEmpty ? "Library Load" : item.name
|
||||
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)
|
||||
|
||||
@@ -23,32 +23,34 @@ struct SystemsView: View {
|
||||
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
|
||||
]
|
||||
private let defaultSystemIconName = "building.2"
|
||||
private let systemIconMappings: [(keywords: [String], icon: String)] = [
|
||||
(["rv", "van", "camper", "motorhome", "coach"], "bus"),
|
||||
(["truck", "trailer", "rig"], "truck.box"),
|
||||
(["boat", "marine", "yacht", "sail"], "sailboat"),
|
||||
(["plane", "air", "flight"], "airplane"),
|
||||
(["ferry", "ship"], "ferry"),
|
||||
(["house", "home", "cabin", "cottage", "lodge"], "house"),
|
||||
(["building", "office", "warehouse", "factory", "facility"], "building"),
|
||||
(["camp", "tent", "outdoor"], "tent"),
|
||||
(["solar", "sun"], "sun.max"),
|
||||
(["battery", "storage"], "battery.100"),
|
||||
(["server", "data", "network", "rack"], "server.rack"),
|
||||
(["computer", "electronics", "lab", "tech"], "cpu"),
|
||||
(["gear", "mechanic", "machine", "workshop"], "gear"),
|
||||
(["tool", "maintenance", "repair", "shop"], "wrench.adjustable"),
|
||||
(["hammer", "carpentry"], "hammer"),
|
||||
(["light", "lighting", "lamp"], "lightbulb"),
|
||||
(["bolt", "power", "electric"], "bolt"),
|
||||
(["plug"], "powerplug"),
|
||||
(["engine", "generator", "motor"], "engine.combustion"),
|
||||
(["fuel", "diesel", "gas"], "fuelpump"),
|
||||
(["water", "pump", "tank"], "drop"),
|
||||
(["heat", "heater", "furnace"], "flame"),
|
||||
(["cold", "freeze", "cool"], "snowflake"),
|
||||
(["climate", "hvac", "temperature"], "thermometer")
|
||||
]
|
||||
private 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()
|
||||
@@ -224,7 +226,10 @@ struct SystemsView: View {
|
||||
}
|
||||
|
||||
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
||||
let baseName = item.name.isEmpty ? String(localized: "default.load.library", comment: "Default name when importing a library load") : item.name
|
||||
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
|
||||
|
||||
@@ -352,6 +357,32 @@ struct SystemsView: View {
|
||||
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
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
"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";
|
||||
@@ -52,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";
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
"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";
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
"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";
|
||||
|
||||
@@ -44,6 +44,30 @@
|
||||
"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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
323
frame_screens.sh
@@ -1,11 +1,58 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
FONT_COLOR="#3C3C3C" # color for light text
|
||||
FONT_BOLD_COLOR="#B51700" # color for bold texto pipefail
|
||||
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 lang subfolders (de/, fr/, en/…)
|
||||
BG_IMAGE="${2:-Shots/frame-bg.png}" # background image (portrait)
|
||||
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
|
||||
@@ -13,12 +60,22 @@ 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=60 # 0–100
|
||||
SHADOW_OPACITY=0 # 0–100
|
||||
SHADOW_BLUR=20 # blur radius
|
||||
SHADOW_OFFSET_X=0 # px
|
||||
SHADOW_OFFSET_Y=40 # px
|
||||
CANVAS_MARGIN=190 # margin around the device on the background, px
|
||||
TITLE_MARGIN=120 # margin above the device for title text, 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"
|
||||
|
||||
@@ -29,28 +86,42 @@ render_mixed_font_title() {
|
||||
local title_y="$3"
|
||||
local output="$4"
|
||||
|
||||
if [[ "$title_text" == *"*"* ]]; then
|
||||
# Get canvas dimensions
|
||||
read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")"
|
||||
local expanded_title
|
||||
expanded_title="$(printf '%b' "$title_text")"
|
||||
|
||||
# Create a temporary image to measure and render text parts
|
||||
local temp_img
|
||||
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
|
||||
cp "$canvas" "$temp_img"
|
||||
local temp_img
|
||||
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
|
||||
cp "$canvas" "$temp_img"
|
||||
|
||||
# Parse text into segments with their font types
|
||||
declare -a text_segments=()
|
||||
declare -a font_types=()
|
||||
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 ${#title_text} ]; do
|
||||
local char="${title_text:$i:1}"
|
||||
|
||||
while [ $i -lt $line_length ]; do
|
||||
local char="${line:$i:1}"
|
||||
if [[ "$char" == "*" ]]; then
|
||||
# Save current segment (even if empty, to handle cases like "**")
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
@@ -58,7 +129,6 @@ render_mixed_font_title() {
|
||||
font_types+=("light")
|
||||
fi
|
||||
current_text=""
|
||||
# Toggle bold state
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
in_bold=false
|
||||
else
|
||||
@@ -70,94 +140,61 @@ render_mixed_font_title() {
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# Handle remaining text
|
||||
if [[ -n "$current_text" ]]; then
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
else
|
||||
font_types+=("light")
|
||||
fi
|
||||
text_segments+=("$current_text")
|
||||
if [[ "$in_bold" == true ]]; then
|
||||
font_types+=("bold")
|
||||
else
|
||||
font_types+=("light")
|
||||
fi
|
||||
|
||||
# Debug: print segments (remove this later)
|
||||
echo "DEBUG: Text segments:"
|
||||
local debug_i=0
|
||||
while [ $debug_i -lt ${#text_segments[@]} ]; do
|
||||
echo " [$debug_i]: '${text_segments[$debug_i]}' (${font_types[$debug_i]})"
|
||||
debug_i=$((debug_i + 1))
|
||||
done
|
||||
|
||||
# Calculate total width
|
||||
local total_width=0
|
||||
local j=0
|
||||
while [ $j -lt ${#text_segments[@]} ]; do
|
||||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||||
local segment="${text_segments[$j]}"
|
||||
local font_type="${font_types[$j]}"
|
||||
|
||||
# Skip empty segments for width calculation
|
||||
if [[ -n "$segment" ]]; then
|
||||
local font_for_measurement="$FONT"
|
||||
if [[ "$font_type" == "bold" ]]; then
|
||||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||||
font_for_measurement="$FONT_BOLD"
|
||||
fi
|
||||
|
||||
# Replace leading/trailing spaces with non-breaking spaces for measurement
|
||||
local segment_for_measurement="$segment"
|
||||
segment_for_measurement="${segment_for_measurement/#/ }" # leading space
|
||||
segment_for_measurement="${segment_for_measurement/%/ }" # trailing space
|
||||
|
||||
local part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
|
||||
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
|
||||
j=$((j + 1))
|
||||
done
|
||||
|
||||
# Calculate starting X position to center the entire text
|
||||
if (( total_width <= 0 )); then
|
||||
continue
|
||||
fi
|
||||
|
||||
local start_x=$(( (canvas_w - total_width) / 2 ))
|
||||
|
||||
# Render each segment
|
||||
local x_offset=0
|
||||
j=0
|
||||
while [ $j -lt ${#text_segments[@]} ]; do
|
||||
for ((j = 0; j < ${#text_segments[@]}; j++)); do
|
||||
local segment="${text_segments[$j]}"
|
||||
local font_type="${font_types[$j]}"
|
||||
|
||||
# Skip empty segments for rendering
|
||||
if [[ -n "$segment" ]]; then
|
||||
local font_to_use="$FONT"
|
||||
local color_to_use="$FONT_COLOR"
|
||||
if [[ "$font_type" == "bold" ]]; then
|
||||
if [[ "${font_types[$j]}" == "bold" ]]; then
|
||||
font_to_use="$FONT_BOLD"
|
||||
color_to_use="$FONT_BOLD_COLOR"
|
||||
fi
|
||||
|
||||
# Replace leading/trailing spaces with non-breaking spaces for rendering
|
||||
local segment_for_rendering="$segment"
|
||||
segment_for_rendering="${segment_for_rendering/#/ }" # leading space
|
||||
segment_for_rendering="${segment_for_rendering/%/ }" # trailing space
|
||||
|
||||
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))+${title_y}" "$segment_for_rendering" \
|
||||
-gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \
|
||||
"$temp_img"
|
||||
|
||||
# Calculate width of rendered text for next position (use same processed segment)
|
||||
local text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
|
||||
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
|
||||
j=$((j + 1))
|
||||
done
|
||||
done
|
||||
|
||||
cp "$temp_img" "$output"
|
||||
rm -f "$temp_img"
|
||||
else
|
||||
# No asterisks, simple rendering
|
||||
magick "$canvas" \
|
||||
-font "$FONT" -pointsize 148 -fill "$FONT_COLOR" \
|
||||
-gravity north -annotate "+0+${title_y}" "$title_text" \
|
||||
"$output"
|
||||
fi
|
||||
cp "$temp_img" "$output"
|
||||
rm -f "$temp_img"
|
||||
}
|
||||
|
||||
# Function to get title from config file
|
||||
@@ -189,11 +226,15 @@ get_title() {
|
||||
|
||||
# Function to frame one screenshot
|
||||
frame_one () {
|
||||
local in="$1" # input screenshot (e.g., 1320x2868)
|
||||
local out="$2" # output image
|
||||
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 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")"
|
||||
@@ -230,8 +271,8 @@ frame_one () {
|
||||
# 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 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"
|
||||
@@ -242,35 +283,125 @@ frame_one () {
|
||||
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
|
||||
|
||||
# Calculate title position (center horizontally, positioned above the screenshot)
|
||||
local title_y=$((TITLE_MARGIN - 10)) # 10px from top of title margin
|
||||
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 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 "1320x2868^" -gravity center -extent "1320x2868" "$out"
|
||||
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
|
||||
for langdir in "$SRC_ROOT"/*; do
|
||||
[[ -d "$langdir" ]] || continue
|
||||
rel="$(basename "$langdir")"
|
||||
mkdir -p "$OUT_ROOT/$rel"
|
||||
for shot in "$langdir"/*.png; do
|
||||
base="$(basename "$shot")"
|
||||
frame_one "$shot" "$OUT_ROOT/$rel/$base" "$BG_IMAGE" "$rel" "$base"
|
||||
echo "Framed: $rel/$base"
|
||||
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/"
|
||||
118
shooter.sh
@@ -2,8 +2,22 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCHEME="CableScreenshots"
|
||||
DEVICE="iPhone 17 Pro Max"
|
||||
RUNTIME_OS="26.0" # e.g. "18.1". Leave empty to let Xcode pick.
|
||||
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
|
||||
@@ -23,53 +37,65 @@ resolve_udid() {
|
||||
fi
|
||||
}
|
||||
|
||||
for lang in de fr en es nl; do
|
||||
# Erase all content and settings to ensure a clean simulator state
|
||||
echo "Resetting simulator for a clean start..."
|
||||
UDID=$(resolve_udid "$DEVICE" "$RUNTIME_OS")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
# Fallback: pick any matching (booted or shutdown)
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE" >&2; exit 1
|
||||
fi
|
||||
# Ensure the device is not booted, then fully erase it. Do NOT ignore failures here.
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
xcrun simctl erase "$UDID"
|
||||
echo "Running screenshots for $lang"
|
||||
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
|
||||
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}) ==="
|
||||
|
||||
# Resolve simulator UDID and enforce system language/locale on the simulator itself
|
||||
UDID=$(resolve_udid "$DEVICE" "$RUNTIME_OS")
|
||||
if [[ -z "$UDID" ]]; then
|
||||
# Fallback: pick any matching (booted or shutdown)
|
||||
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE" -F '[()]' '$0 ~ n {print $2; exit}')
|
||||
fi
|
||||
if [[ -z "$UDID" ]]; then
|
||||
echo "Could not resolve UDID for $DEVICE" >&2; exit 1
|
||||
fi
|
||||
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
|
||||
|
||||
# Boot, set system language & locale, then restart the simulator to ensure it sticks
|
||||
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}"
|
||||
# Some versions require a reboot of the sim for language changes to fully apply
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
xcrun simctl boot "$UDID"
|
||||
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:]')
|
||||
|
||||
bundle="results-$lang.xcresult"
|
||||
outdir="Shots/Screenshots/$lang"
|
||||
rm -rf "$bundle" "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
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
|
||||
|
||||
# Note: Simulator system language/locale is enforced via simctl (AppleLanguages/AppleLocale) before each run.
|
||||
xcodebuild test \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$UDID" \
|
||||
-resultBundlePath "$bundle"
|
||||
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
|
||||
|
||||
xcparse screenshots "$bundle" "$outdir"
|
||||
echo "Exported screenshots to $outdir"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
|
||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
||||
rm -rf "$bundle" "$outdir"
|
||||
mkdir -p "$outdir"
|
||||
|
||||
xcodebuild test \
|
||||
-scheme "$SCHEME" \
|
||||
-destination "id=$UDID" \
|
||||
-resultBundlePath "$bundle"
|
||||
|
||||
xcparse screenshots "$bundle" "$outdir"
|
||||
echo "Exported screenshots to $outdir"
|
||||
xcrun simctl shutdown "$UDID" || true
|
||||
done
|
||||
done
|
||||