better presentation fot the App Store

This commit is contained in:
Stefan Lange-Hegermann
2025-10-20 15:35:29 +02:00
parent dd13178f0e
commit 420a6ea014
25 changed files with 1332 additions and 258 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,63 +1,171 @@
{
"fill" : {
"automatic-gradient" : "display-p3:0.50588,0.79216,0.56471,1.00000"
"linear-gradient" : [
"display-p3:0.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

View File

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

View File

@@ -9,6 +9,7 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let id: String
let name: String
let translations: [String: String]
let voltageIn: Double?
let voltageOut: Double?
let watt: Double?
@@ -39,10 +40,24 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return String(format: "%.1fA", current)
}
var localizedName: String {
localizedName(usingPreferredLanguages: Locale.preferredLanguages) ?? name
}
func localizedName(usingPreferredLanguages languages: [String]) -> String? {
guard let primaryIdentifier = languages.first else { return nil }
let locale = Locale(identifier: primaryIdentifier)
return translation(for: locale)
}
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name
}
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
guard !affiliateLinks.isEmpty else { return nil }
@@ -64,6 +79,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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")
}
}

View File

@@ -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 its important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
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()
}
}
}

View File

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

View File

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