Compare commits

...

2 Commits

Author SHA1 Message Date
Stefan Lange-Hegermann
420a6ea014 better presentation fot the App Store 2025-10-20 15:35:29 +02:00
Stefan Lange-Hegermann
dd13178f0e automated screenshot generation 2025-10-13 09:38:22 +02:00
43 changed files with 3034 additions and 2081 deletions

2
.bundle/config Normal file
View File

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

5
.gitignore vendored
View File

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

1
.ruby-version Normal file
View File

@@ -0,0 +1 @@
3.2.4

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

@@ -35,3 +35,35 @@
"system.list.no.components" = "No components yet";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metric (mm², m)";
"sample.system.rv.name" = "Adventure Van";
"sample.system.rv.location" = "12V living circuit";
"sample.system.workshop.name" = "Workshop Bench";
"sample.system.workshop.location" = "Tool corner";
"sample.load.fridge.name" = "Compressor fridge";
"sample.load.lighting.name" = "LED strip lighting";
"sample.load.compressor.name" = "Air compressor";
"sample.load.charger.name" = "Tool charger";
"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach";
"system.icon.keywords.truck" = "truck, trailer, rig";
"system.icon.keywords.boat" = "boat, marine, yacht, sail";
"system.icon.keywords.plane" = "plane, air, flight";
"system.icon.keywords.ferry" = "ferry, ship";
"system.icon.keywords.house" = "house, home, cabin, cottage, lodge";
"system.icon.keywords.building" = "building, office, warehouse, factory, facility";
"system.icon.keywords.tent" = "camp, tent, outdoor";
"system.icon.keywords.solar" = "solar, sun";
"system.icon.keywords.battery" = "battery, storage";
"system.icon.keywords.server" = "server, data, network, rack";
"system.icon.keywords.computer" = "computer, electronics, lab, tech";
"system.icon.keywords.gear" = "gear, mechanic, machine, workshop";
"system.icon.keywords.tool" = "tool, maintenance, repair, shop";
"system.icon.keywords.hammer" = "hammer, carpentry";
"system.icon.keywords.light" = "light, lighting, lamp";
"system.icon.keywords.bolt" = "bolt, power, electric";
"system.icon.keywords.plug" = "plug";
"system.icon.keywords.engine" = "engine, generator, motor";
"system.icon.keywords.fuel" = "fuel, diesel, gas";
"system.icon.keywords.water" = "water, pump, tank";
"system.icon.keywords.heat" = "heat, heater, furnace";
"system.icon.keywords.cold" = "cold, freeze, cool";
"system.icon.keywords.climate" = "climate, hvac, temperature";

View File

@@ -30,6 +30,12 @@ struct CableApp: App {
}
}()
init() {
#if DEBUG
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
#endif
}
var body: some Scene {
WindowGroup {
ContentView()

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 {
@@ -346,6 +518,7 @@ struct ComponentLibraryView: View {
Button("Close") {
dismiss()
}
.accessibilityIdentifier("library-view-close-button")
}
}
}
@@ -395,14 +568,17 @@ struct ComponentLibraryView: View {
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
List(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
List {
ForEach(filteredItems) { item in
Button {
onSelect(item)
dismiss()
} label: {
ComponentRow(item: item)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
poweredByVoltplanRow
}
.listStyle(.insetGrouped)
}
@@ -413,7 +589,30 @@ struct ComponentLibraryView: View {
guard !trimmedQuery.isEmpty else { return viewModel.items }
return viewModel.items.filter { item in
item.name.localizedCaseInsensitiveContains(trimmedQuery)
let localizedName = item.localizedName
return localizedName.localizedCaseInsensitiveContains(trimmedQuery)
|| item.name.localizedCaseInsensitiveContains(trimmedQuery)
}
}
@ViewBuilder
private var poweredByVoltplanRow: some View {
if let url = URL(string: "https://voltplan.app") {
Section {
Link(destination: url) {
Image("PoweredByVoltplan")
.renderingMode(.original)
.resizable()
.scaledToFit()
.frame(maxWidth: 220)
.padding(.vertical, 20)
.frame(maxWidth: .infinity)
.accessibilityLabel("Powered by Voltplan")
}
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
}
.textCase(nil)
}
}
}
@@ -425,7 +624,7 @@ private struct ComponentRow: View {
HStack(spacing: 12) {
iconView
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
Text(item.localizedName)
.font(.headline)
.foregroundColor(.primary)
detailLine

View File

@@ -79,6 +79,7 @@ struct ComponentsOnboardingView: View {
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
)
}
.accessibilityIdentifier("select-component-button")
.buttonStyle(.plain)
}
.padding(.horizontal, 24)

File diff suppressed because it is too large Load Diff

387
Cable/LoadsView.swift Normal file
View File

@@ -0,0 +1,387 @@
//
// LoadsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct LoadsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@State private var newLoadToEdit: SavedLoad?
@State private var showingSystemEditor = false
@State private var hasPresentedSystemEditorOnAppear = false
@State private var hasOpenedLoadOnAppear = false
@State private var showingComponentLibrary = false
@State private var showingSystemBOM = false
let system: ElectricalSystem
private let presentSystemEditorOnAppear: Bool
private let loadToOpenOnAppear: SavedLoad?
init(system: ElectricalSystem, presentSystemEditorOnAppear: Bool = false, loadToOpenOnAppear: SavedLoad? = nil) {
self.system = system
self.presentSystemEditorOnAppear = presentSystemEditorOnAppear
self.loadToOpenOnAppear = loadToOpenOnAppear
}
private var savedLoads: [SavedLoad] {
allLoads.filter { $0.system == system }
}
var body: some View {
VStack(spacing: 0) {
if savedLoads.isEmpty {
emptyStateView
} else {
librarySection
List {
ForEach(savedLoads) { load in
NavigationLink(destination: CalculatorView(savedLoad: load)) {
HStack(spacing: 12) {
LoadIconView(
remoteIconURLString: load.remoteIconURLString,
fallbackSystemName: load.iconName,
fallbackColor: colorForName(load.colorName),
size: 44)
VStack(alignment: .leading, spacing: 6) {
Text(load.name)
.fontWeight(.medium)
.lineLimit(1)
.truncationMode(.tail)
// Secondary info
HStack {
Group {
Text(String(format: "%.1fV", load.voltage))
Text("")
if load.isWattMode {
Text(String(format: "%.0fW", load.power))
} else {
Text(String(format: "%.1fA", load.current))
}
Text("")
Text(String(format: "%.1f%@",
unitSettings.unitSystem == .metric ? load.length : load.length * 3.28084,
unitSettings.unitSystem.lengthUnit))
}
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
// Prominent fuse and wire gauge display
HStack(spacing: 12) {
HStack(spacing: 4) {
Text("FUSE")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
Text("\(recommendedFuse(for: load))A")
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.orange)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.1))
.cornerRadius(6)
HStack(spacing: 4) {
Text("WIRE")
.font(.caption2)
.fontWeight(.medium)
.foregroundColor(.secondary)
Text(String(format: unitSettings.unitSystem == .imperial ? "%.0f AWG" : "%.1fmm²",
unitSettings.unitSystem == .imperial ? awgFromCrossSection(load.crossSection) : load.crossSection))
.font(.subheadline)
.fontWeight(.bold)
.foregroundColor(.blue)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.cornerRadius(6)
Spacer()
}
}
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteLoads)
}
.accessibilityIdentifier("loads-list")
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
Button(action: {
showingSystemEditor = true
}) {
HStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 6)
.fill(colorForName(system.colorName))
.frame(width: 24, height: 24)
Image(systemName: system.iconName)
.font(.system(size: 12))
.foregroundColor(.white)
}
Text(system.name)
.font(.headline)
.fontWeight(.semibold)
.foregroundColor(.primary)
}
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
if !savedLoads.isEmpty {
Button(action: {
showingSystemBOM = true
}) {
Image(systemName: "list.bullet.rectangle")
}
.accessibilityIdentifier("system-bom-button")
}
Button(action: {
createNewLoad()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $newLoadToEdit) { load in
CalculatorView(savedLoad: load)
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponent(item)
}
}
.sheet(isPresented: $showingSystemBOM) {
SystemBillOfMaterialsView(
systemName: system.name,
loads: savedLoads,
unitSystem: unitSettings.unitSystem
)
}
.sheet(isPresented: $showingSystemEditor) {
SystemEditorView(
systemName: Binding(
get: { system.name },
set: { system.name = $0 }
),
location: Binding(
get: { system.location },
set: { system.location = $0 }
),
iconName: Binding(
get: { system.iconName },
set: { system.iconName = $0 }
),
colorName: Binding(
get: { system.colorName },
set: { system.colorName = $0 }
)
)
}
.onAppear {
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
hasPresentedSystemEditorOnAppear = true
DispatchQueue.main.async {
showingSystemEditor = true
}
}
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
hasOpenedLoadOnAppear = true
DispatchQueue.main.async {
newLoadToEdit = loadToOpen
}
}
}
}
private var librarySection: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Component Library")
.font(.headline)
.fontWeight(.semibold)
Text("Browse electrical components from VoltPlan")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
showingComponentLibrary = true
}) {
HStack(spacing: 6) {
Text("Browse")
.font(.subheadline)
.fontWeight(.medium)
Image(systemName: "arrow.up.right")
.font(.caption)
}
.foregroundColor(.blue)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color(.systemGroupedBackground))
Divider()
}
}
private var emptyStateView: some View {
ComponentsOnboardingView(
onCreate: { createNewLoad() },
onBrowse: { showingComponentLibrary = true }
)
}
private func deleteLoads(offsets: IndexSet) {
withAnimation {
for index in offsets {
modelContext.delete(savedLoads[index])
}
}
}
private func createNewLoad() {
let defaultName = String(localized: "default.load.new", comment: "Default name when creating a new load from system view")
let loadName = uniqueLoadName(startingWith: defaultName)
let newLoad = SavedLoad(
name: loadName,
voltage: 12.0,
current: 5.0,
power: 60.0, // 12V * 5A = 60W
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: false,
system: system,
remoteIconURLString: nil
)
modelContext.insert(newLoad)
// Navigate to the new load
newLoadToEdit = newLoad
}
private func addComponent(_ item: ComponentLibraryItem) {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty ? "Library Load" : localizedName
let loadName = uniqueLoadName(startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0)
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
newLoadToEdit = newLoad
}
private func uniqueLoadName(startingWith baseName: String) -> String {
let existingNames = Set(savedLoads.map { $0.name })
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
private func colorForName(_ colorName: String) -> Color {
switch colorName {
case "blue": return .blue
case "green": return .green
case "orange": return .orange
case "red": return .red
case "purple": return .purple
case "yellow": return .yellow
case "pink": return .pink
case "teal": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "cyan": return .cyan
case "brown": return .brown
case "gray": return .gray
default: return .blue
}
}
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let awgSizes = [(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31),
(10, 5.26), (8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6),
(1, 42.4), (0, 53.5), (00, 67.4), (000, 85.0), (0000, 107.0)]
// Find the closest AWG size
let closest = awgSizes.min { abs($0.1 - crossSectionMM2) < abs($1.1 - crossSectionMM2) }
return Double(closest?.0 ?? 20)
}
private func recommendedFuse(for load: SavedLoad) -> Int {
let targetFuse = load.current * 1.25 // 125% of load current for safety
// Common fuse values in amperes
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
// Find the smallest standard fuse that's >= target
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last!
}
}

91
Cable/SettingsView.swift Normal file
View File

@@ -0,0 +1,91 @@
//
// SettingsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SettingsView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("Units") {
Picker("Unit System", selection: $unitSettings.unitSystem) {
ForEach(UnitSystem.allCases, id: \.self) { system in
Text(system.displayName).tag(system)
}
}
.pickerStyle(.segmented)
}
Section {
HStack {
Text("Wire Cross-Section:")
Spacer()
Text(unitSettings.unitSystem.wireAreaUnit)
.foregroundColor(.secondary)
}
HStack {
Text("Length:")
Spacer()
Text(unitSettings.unitSystem.lengthUnit)
.foregroundColor(.secondary)
}
} header: {
Text("Current Units")
} footer: {
Text("Changing the unit system will apply to all calculations in the app.")
}
Section {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.system(size: 18))
Text("Safety Disclaimer")
.font(.headline)
.fontWeight(.semibold)
}
VStack(alignment: .leading, spacing: 8) {
Text("This application provides electrical calculations for educational and estimation purposes only.")
.font(.body)
Text("Important:")
.font(.subheadline)
.fontWeight(.semibold)
VStack(alignment: .leading, spacing: 4) {
Text("• Always consult qualified electricians for actual installations")
Text("• Follow all local electrical codes and regulations")
Text("• Electrical work should only be performed by licensed professionals")
Text("• These calculations may not account for all environmental factors")
Text("• The app developers assume no liability for electrical installations")
}
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Close") {
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,382 @@
//
// SystemBillOfMaterialsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemBillOfMaterialsView: View {
let systemName: String
let loads: [SavedLoad]
let unitSystem: UnitSystem
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var completedItemIDs: Set<String>
@State private var suppressRowTapForID: String?
private struct Item: Identifiable {
enum Destination {
case affiliate(URL)
case amazonSearch(String)
}
let id: String
let logicalID: String
let title: String
let detail: String
let iconSystemName: String
let destination: Destination
let isPrimaryComponent: Bool
}
init(systemName: String, loads: [SavedLoad], unitSystem: UnitSystem) {
self.systemName = systemName
self.loads = loads
self.unitSystem = unitSystem
let initialKeys = loads.flatMap { load in
load.bomCompletedItemIDs.map { SystemBillOfMaterialsView.storageKey(for: load, itemID: $0) }
}
_completedItemIDs = State(initialValue: Set(initialKeys))
_suppressRowTapForID = State(initialValue: nil)
}
var body: some View {
NavigationStack {
List {
if sortedLoads.isEmpty {
Section("Components") {
Text("No loads saved in this system yet.")
.font(.footnote)
.foregroundColor(.secondary)
}
} else {
ForEach(sortedLoads) { load in
Section(header: sectionHeader(for: load)) {
ForEach(items(for: load)) { item in
let isCompleted = completedItemIDs.contains(item.id)
let destinationURL = destinationURL(for: item.destination, load: load)
HStack(spacing: 12) {
let accessibilityLabel: String = {
if isCompleted {
let format = NSLocalizedString(
"bom.accessibility.mark.incomplete",
comment: "Accessibility label instructing VoiceOver to mark an item incomplete"
)
return String.localizedStringWithFormat(format, item.title)
} else {
let format = NSLocalizedString(
"bom.accessibility.mark.complete",
comment: "Accessibility label instructing VoiceOver to mark an item complete"
)
return String.localizedStringWithFormat(format, item.title)
}
}()
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(isCompleted ? .accentColor : .secondary)
.imageScale(.large)
.onTapGesture {
setCompletion(!isCompleted, for: load, item: item)
suppressRowTapForID = item.id
}
.accessibilityLabel(accessibilityLabel)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
.foregroundStyle(isCompleted ? Color.primary.opacity(0.7) : Color.primary)
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
if item.isPrimaryComponent {
Text(String(localized: "component.fallback.name", comment: "Tag label marking an item as the component itself"))
.font(.caption2.weight(.medium))
.foregroundColor(.accentColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentColor.opacity(0.15), in: Capsule())
}
Text(item.detail)
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer(minLength: 8)
if destinationURL != nil {
Image(systemName: "arrow.up.right")
.font(.footnote.weight(.semibold))
.foregroundColor(.secondary)
}
}
.padding(.vertical, 10)
.contentShape(Rectangle())
.listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 16))
.listRowBackground(
Color(.secondarySystemGroupedBackground)
)
.onTapGesture {
if suppressRowTapForID == item.id {
suppressRowTapForID = nil
return
}
if let destinationURL {
openURL(destinationURL)
}
setCompletion(true, for: load, item: item)
suppressRowTapForID = nil
suppressRowTapForID = nil
}
}
}
}
}
Section {
Text(footerMessage)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
.navigationTitle(
String(
format: NSLocalizedString(
"bom.navigation.title.system",
comment: "Navigation title for the bill of materials view"
),
locale: Locale.current,
systemName
)
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
.onAppear {
refreshCompletedItems()
suppressRowTapForID = nil
}
}
.accessibilityIdentifier("system-bom-view")
}
private var sortedLoads: [SavedLoad] {
loads.sorted { lhs, rhs in
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
}
private func sectionHeader(for load: SavedLoad) -> some View {
VStack(alignment: .leading, spacing: 2) {
let fallbackTitle = String(localized: "component.fallback.name", comment: "Fallback title for a component that lacks a name")
Text(load.name.isEmpty ? fallbackTitle : load.name)
.font(.headline)
Text(dateFormatter.string(from: load.timestamp))
.font(.caption)
.foregroundColor(.secondary)
}
}
private func items(for load: SavedLoad) -> [Item] {
let lengthValue: Double
if unitSystem == .imperial {
lengthValue = load.length * 3.28084
} else {
lengthValue = load.length
}
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
let crossSectionLabel: String
let gaugeQuery: String
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is not yet determined")
if unitSystem == .imperial {
let awg = awgFromCrossSection(load.crossSection)
if awg > 0 {
crossSectionLabel = String(format: "AWG %.0f", awg)
gaugeQuery = String(format: "AWG %.0f", awg)
} else {
crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable"
}
} else {
if load.crossSection > 0 {
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
} else {
crossSectionLabel = unknownSizeLabel
gaugeQuery = "battery cable"
}
}
let cableDetail = "\(lengthLabel)\(crossSectionLabel)"
let calculatedPower = load.power > 0 ? load.power : load.voltage * load.current
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
let fuseRating = recommendedFuse(for: load)
let fuseDetailFormat = NSLocalizedString(
"bom.fuse.detail",
comment: "Description for the fuse item in the BOM list"
)
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
let cableShoesDetailFormat = NSLocalizedString(
"bom.terminals.detail",
comment: "Description for the cable terminals item in the BOM list"
)
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceQuery = load.name.isEmpty
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage)
: load.name
let redCableQuery = "\(gaugeQuery) red battery cable"
let blackCableQuery = "\(gaugeQuery) black battery cable"
let fuseQuery = "inline fuse holder \(fuseRating)A"
let terminalQuery = "\(gaugeQuery) cable shoes"
let items: [Item] = [
Item(
id: Self.storageKey(for: load, itemID: "component"),
logicalID: "component",
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
detail: powerDetail,
iconSystemName: "bolt.fill",
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
isPrimaryComponent: true
),
Item(
id: Self.storageKey(for: load, itemID: "cable-red"),
logicalID: "cable-red",
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery),
isPrimaryComponent: false
),
Item(
id: Self.storageKey(for: load, itemID: "cable-black"),
logicalID: "cable-black",
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail,
iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery),
isPrimaryComponent: false
),
Item(
id: Self.storageKey(for: load, itemID: "fuse"),
logicalID: "fuse",
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
detail: fuseDetail,
iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery),
isPrimaryComponent: false
),
Item(
id: Self.storageKey(for: load, itemID: "terminals"),
logicalID: "terminals",
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery),
isPrimaryComponent: false
)
]
return items
}
private func destinationURL(for destination: Item.Destination, load: SavedLoad) -> URL? {
switch destination {
case .affiliate(let url):
return url
case .amazonSearch(let query):
let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
}
}
private static func storageKey(for load: SavedLoad, itemID: String) -> String {
if load.identifier.isEmpty {
load.identifier = UUID().uuidString
}
return "\(load.identifier)::\(itemID)"
}
private func setCompletion(_ isCompleted: Bool, for load: SavedLoad, item: Item) {
if isCompleted {
completedItemIDs.insert(item.id)
} else {
completedItemIDs.remove(item.id)
}
if load.identifier.isEmpty {
load.identifier = UUID().uuidString
}
var stored = Set(load.bomCompletedItemIDs)
if isCompleted {
stored.insert(item.logicalID)
} else {
stored.remove(item.logicalID)
}
load.bomCompletedItemIDs = Array(stored).sorted()
}
private func refreshCompletedItems() {
let keys = loads.flatMap { load in
load.bomCompletedItemIDs.map { Self.storageKey(for: load, itemID: $0) }
}
completedItemIDs = Set(keys)
}
private func recommendedFuse(for load: SavedLoad) -> Int {
let targetFuse = load.current * 1.25
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last ?? 0
}
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
let mapping: [(awg: Double, area: Double)] = [
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26),
(8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (0, 53.5),
(00, 67.4), (000, 85.0), (0000, 107.0)
]
guard crossSectionMM2 > 0 else { return 0 }
let closest = mapping.min { lhs, rhs in
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
}
return closest?.awg ?? 0
}
private var footerMessage: String {
NSLocalizedString(
"affiliate.disclaimer",
comment: "Footer note reminding users that affiliate purchases may support the app"
)
}
private var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}
}

40
Cable/SystemView.swift Normal file
View File

@@ -0,0 +1,40 @@
//
// SystemView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemView: View {
var body: some View {
NavigationStack {
VStack(spacing: 24) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "square.grid.3x2")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("System View")
.font(.title2)
.fontWeight(.semibold)
Text("Coming soon - manage your electrical systems and panels here.")
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 48)
}
Spacer()
Spacer()
}
.navigationTitle("System")
}
}
}

404
Cable/SystemsView.swift Normal file
View File

@@ -0,0 +1,404 @@
//
// SystemsView.swift
// Cable
//
// Created by Stefan Lange-Hegermann on 09.10.25.
//
import SwiftUI
import SwiftData
struct SystemsView: View {
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var unitSettings: UnitSystemSettings
@Query(sort: \ElectricalSystem.timestamp, order: .reverse) private var systems: [ElectricalSystem]
@Query(sort: \SavedLoad.timestamp, order: .reverse) private var allLoads: [SavedLoad]
@State private var systemNavigationTarget: SystemNavigationTarget?
@State private var showingComponentLibrary = false
@State private var showingSettings = false
private let systemColorOptions = [
"blue", "green", "orange", "red", "purple", "yellow",
"pink", "teal", "indigo", "mint", "cyan", "brown", "gray"
]
private let defaultSystemIconName = "building.2"
private var systemIconMappings: [(keywords: [String], icon: String)] {
[
(keywords(for: "system.icon.keywords.rv", fallback: ["rv", "van", "camper", "motorhome", "coach"]), "bus"),
(keywords(for: "system.icon.keywords.truck", fallback: ["truck", "trailer", "rig"]), "truck.box"),
(keywords(for: "system.icon.keywords.boat", fallback: ["boat", "marine", "yacht", "sail"]), "sailboat"),
(keywords(for: "system.icon.keywords.plane", fallback: ["plane", "air", "flight"]), "airplane"),
(keywords(for: "system.icon.keywords.ferry", fallback: ["ferry", "ship"]), "ferry"),
(keywords(for: "system.icon.keywords.house", fallback: ["house", "home", "cabin", "cottage", "lodge"]), "house"),
(keywords(for: "system.icon.keywords.building", fallback: ["building", "office", "warehouse", "factory", "facility"]), "building"),
(keywords(for: "system.icon.keywords.tent", fallback: ["camp", "tent", "outdoor"]), "tent"),
(keywords(for: "system.icon.keywords.solar", fallback: ["solar", "sun"]), "sun.max"),
(keywords(for: "system.icon.keywords.battery", fallback: ["battery", "storage"]), "battery.100"),
(keywords(for: "system.icon.keywords.server", fallback: ["server", "data", "network", "rack"]), "server.rack"),
(keywords(for: "system.icon.keywords.computer", fallback: ["computer", "electronics", "lab", "tech"]), "cpu"),
(keywords(for: "system.icon.keywords.gear", fallback: ["gear", "mechanic", "machine", "workshop"]), "gear"),
(keywords(for: "system.icon.keywords.tool", fallback: ["tool", "maintenance", "repair", "shop"]), "wrench.adjustable"),
(keywords(for: "system.icon.keywords.hammer", fallback: ["hammer", "carpentry"]), "hammer"),
(keywords(for: "system.icon.keywords.light", fallback: ["light", "lighting", "lamp"]), "lightbulb"),
(keywords(for: "system.icon.keywords.bolt", fallback: ["bolt", "power", "electric"]), "bolt"),
(keywords(for: "system.icon.keywords.plug", fallback: ["plug"]), "powerplug"),
(keywords(for: "system.icon.keywords.engine", fallback: ["engine", "generator", "motor"]), "engine.combustion"),
(keywords(for: "system.icon.keywords.fuel", fallback: ["fuel", "diesel", "gas"]), "fuelpump"),
(keywords(for: "system.icon.keywords.water", fallback: ["water", "pump", "tank"]), "drop"),
(keywords(for: "system.icon.keywords.heat", fallback: ["heat", "heater", "furnace"]), "flame"),
(keywords(for: "system.icon.keywords.cold", fallback: ["cold", "freeze", "cool"]), "snowflake"),
(keywords(for: "system.icon.keywords.climate", fallback: ["climate", "hvac", "temperature"]), "thermometer")
]
}
private struct SystemNavigationTarget: Identifiable, Hashable {
let id = UUID()
let system: ElectricalSystem
let presentSystemEditor: Bool
let loadToOpenOnAppear: SavedLoad?
static func == (lhs: SystemNavigationTarget, rhs: SystemNavigationTarget) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
var body: some View {
NavigationStack {
Group {
if systems.isEmpty {
systemsEmptyState
} else {
List {
ForEach(systems) { system in
NavigationLink(destination: LoadsView(system: system)) {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(colorForName(system.colorName))
.frame(width: 44, height: 44)
Image(systemName: system.iconName)
.font(.title3)
.foregroundColor(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(system.name)
.fontWeight(.medium)
if !system.location.isEmpty {
Text(system.location)
.font(.caption)
.foregroundColor(.secondary)
}
Text(componentSummary(for: system))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
.onDelete(perform: deleteSystems)
}
.accessibilityIdentifier("systems-list")
}
}
.navigationTitle("Systems")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
showingSettings = true
} label: {
Image(systemName: "gearshape")
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
createNewSystem()
}) {
Image(systemName: "plus")
}
EditButton()
}
}
}
.navigationDestination(item: $systemNavigationTarget) { target in
LoadsView(
system: target.system,
presentSystemEditorOnAppear: target.presentSystemEditor,
loadToOpenOnAppear: target.loadToOpenOnAppear
)
}
}
.sheet(isPresented: $showingComponentLibrary) {
ComponentLibraryView { item in
addComponentFromLibrary(item)
}
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.environmentObject(unitSettings)
}
}
private var systemsEmptyState: some View {
SystemsOnboardingView { name in
createOnboardingSystem(named: name)
}
}
private func createNewSystem() {
let system = makeSystem()
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
}
private func createNewSystem(named name: String) {
let system = makeSystem(preferredName: name)
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
}
private func createOnboardingSystem(named name: String) {
let system = makeSystem(
preferredName: name,
colorName: randomSystemColorName()
)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
}
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
let target = SystemNavigationTarget(
system: system,
presentSystemEditor: presentSystemEditor,
loadToOpenOnAppear: loadToOpen
)
if animated {
systemNavigationTarget = target
} else {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
systemNavigationTarget = target
}
}
}
@discardableResult
private func makeSystem(preferredName: String? = nil, colorName: String? = nil, iconName: String? = nil) -> ElectricalSystem {
let existingNames = Set(systems.map { $0.name })
let trimmedPreferred = preferredName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let baseName = trimmedPreferred.isEmpty ? String(localized: "default.system.new", comment: "Default name for a newly created system") : trimmedPreferred
var systemName = baseName
var counter = 2
while existingNames.contains(systemName) {
systemName = "\(baseName) \(counter)"
counter += 1
}
let resolvedColorName = colorName ?? "blue"
let resolvedIconName = iconName ?? systemIconName(for: systemName)
let newSystem = ElectricalSystem(
name: systemName,
location: "",
iconName: resolvedIconName,
colorName: resolvedColorName
)
modelContext.insert(newSystem)
return newSystem
}
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
let system = makeSystem()
let load = createLoad(from: item, in: system)
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
}
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
let localizedName = item.localizedName
let baseName = localizedName.isEmpty
? String(localized: "default.load.library", comment: "Default name when importing a library load")
: localizedName
let loadName = uniqueLoadName(for: system, startingWith: baseName)
let voltage = item.displayVoltage ?? 12.0
let power: Double
if let watt = item.watt {
power = watt
} else if let derivedCurrent = item.current, voltage > 0 {
power = derivedCurrent * voltage
} else {
power = 0
}
let current: Double
if let explicitCurrent = item.current {
current = explicitCurrent
} else if voltage > 0 {
current = power / voltage
} else {
current = 0
}
let affiliateLink = item.primaryAffiliateLink
let newLoad = SavedLoad(
name: loadName,
voltage: voltage,
current: current,
power: power,
length: 10.0,
crossSection: 1.0,
iconName: "lightbulb",
colorName: "blue",
isWattMode: item.watt != nil,
system: system,
remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString,
affiliateCountryCode: affiliateLink?.country
)
modelContext.insert(newLoad)
return newLoad
}
private func uniqueLoadName(for system: ElectricalSystem, startingWith baseName: String) -> String {
let descriptor = FetchDescriptor<SavedLoad>()
let fetchedLoads = (try? modelContext.fetch(descriptor)) ?? []
let existingNames = Set(fetchedLoads.filter { $0.system == system }.map { $0.name })
if !existingNames.contains(baseName) {
return baseName
}
var counter = 2
var candidate = "\(baseName) \(counter)"
while existingNames.contains(candidate) {
counter += 1
candidate = "\(baseName) \(counter)"
}
return candidate
}
private func deleteSystems(offsets: IndexSet) {
withAnimation {
for index in offsets {
let system = systems[index]
deleteLoads(for: system)
modelContext.delete(system)
}
}
}
private func deleteLoads(for system: ElectricalSystem) {
let descriptor = FetchDescriptor<SavedLoad>()
if let loads = try? modelContext.fetch(descriptor) {
for load in loads where load.system == system {
modelContext.delete(load)
}
}
}
private func loads(for system: ElectricalSystem) -> [SavedLoad] {
allLoads.filter { $0.system == system }
}
private func componentSummary(for system: ElectricalSystem) -> String {
let systemLoads = loads(for: system)
guard !systemLoads.isEmpty else {
return String(localized: "system.list.no.components", comment: "Message shown when a system has no components yet")
}
let count = systemLoads.count
let totalPower = systemLoads.reduce(0.0) { $0 + $1.power }
let formattedPower: String
if totalPower >= 1000 {
formattedPower = String(format: "%.1fkW", totalPower / 1000)
} else {
formattedPower = String(format: "%.0fW", totalPower)
}
let format = NSLocalizedString(
"system.list.component.summary",
comment: "Summary showing number of components and the total power"
)
return String.localizedStringWithFormat(format, count, formattedPower)
}
private func randomSystemColorName() -> String {
systemColorOptions.randomElement() ?? "blue"
}
private func systemIconName(for name: String) -> String {
let normalized = name
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
.lowercased()
for mapping in systemIconMappings {
if mapping.keywords.contains(where: { keyword in normalized.contains(keyword) }) {
return mapping.icon
}
}
return defaultSystemIconName
}
private func keywords(for localizationKey: String, fallback: [String]) -> [String] {
let fallbackValue = fallback.joined(separator: ",")
let localizedKeywords = NSLocalizedString(
localizationKey,
tableName: nil,
bundle: .main,
value: fallbackValue,
comment: ""
)
let separators = CharacterSet(charactersIn: ",;")
let components = localizedKeywords
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
.filter { !$0.isEmpty }
var uniqueKeywords: [String] = []
for keyword in fallback.map({ $0.lowercased() }) + components {
if !uniqueKeywords.contains(keyword) {
uniqueKeywords.append(keyword)
}
}
return uniqueKeywords
}
private func colorForName(_ colorName: String) -> Color {
switch colorName {
case "blue": return .blue
case "green": return .green
case "orange": return .orange
case "red": return .red
case "purple": return .purple
case "yellow": return .yellow
case "pink": return .pink
case "teal": return .teal
case "indigo": return .indigo
case "mint": return .mint
case "cyan": return .cyan
case "brown": return .brown
case "gray": return .gray
default: return .blue
}
}
}

View File

@@ -0,0 +1,128 @@
//
// UITestSampleData.swift
// Cable
// Created by Stefan Lange-Hegermann on 06.10.25.
import Foundation
import SwiftData
enum UITestSampleData {
static let argument = "--uitest-sample-data"
static func prepareIfNeeded(container: ModelContainer) {
#if DEBUG
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
let context = ModelContext(container)
do {
try clearExistingData(in: context)
try seedSampleData(in: context)
try context.save()
} catch {
assertionFailure("Failed to seed UI test sample data: \(error)")
}
#endif
}
}
#if DEBUG
private extension UITestSampleData {
static func clearExistingData(in context: ModelContext) throws {
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
let loadDescriptor = FetchDescriptor<SavedLoad>()
let itemDescriptor = FetchDescriptor<Item>()
let systems = try context.fetch(systemDescriptor)
let loads = try context.fetch(loadDescriptor)
let items = try context.fetch(itemDescriptor)
systems.forEach { context.delete($0) }
loads.forEach { context.delete($0) }
items.forEach { context.delete($0) }
}
static func seedSampleData(in context: ModelContext) throws {
let adventureVan = ElectricalSystem(
name: String(localized: "sample.system.rv.name", comment: "Sample data name for the adventure van system"),
location: String(localized: "sample.system.rv.location", comment: "Sample data location for the adventure van system"),
iconName: "bus",
colorName: "orange"
)
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
let workshopBench = ElectricalSystem(
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
location: String(localized: "sample.system.workshop.location", comment: "Sample data location for the workshop system"),
iconName: "wrench.adjustable",
colorName: "teal"
)
workshopBench.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
context.insert(adventureVan)
context.insert(workshopBench)
let vanFridge = SavedLoad(
name: String(localized: "sample.load.fridge.name", comment: "Sample data load name for a compressor fridge"),
voltage: 12.0,
current: 4.2,
power: 50.0,
length: 6.0,
crossSection: 6.0,
iconName: "snowflake",
colorName: "blue",
isWattMode: true,
system: adventureVan,
identifier: "sample.load.fridge"
)
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
let vanLighting = SavedLoad(
name: String(localized: "sample.load.lighting.name", comment: "Sample data load name for LED strip lighting"),
voltage: 12.0,
current: 2.0,
power: 24.0,
length: 10.0,
crossSection: 2.5,
iconName: "lightbulb",
colorName: "yellow",
isWattMode: false,
system: adventureVan,
identifier: "sample.load.lighting"
)
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
let workshopCompressor = SavedLoad(
name: String(localized: "sample.load.compressor.name", comment: "Sample data load name for an air compressor"),
voltage: 120.0,
current: 8.0,
power: 960.0,
length: 15.0,
crossSection: 16.0,
iconName: "hammer",
colorName: "red",
isWattMode: true,
system: workshopBench,
identifier: "sample.load.compressor"
)
workshopCompressor.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
let workshopCharger = SavedLoad(
name: String(localized: "sample.load.charger.name", comment: "Sample data load name for a tool charger"),
voltage: 120.0,
current: 3.5,
power: 420.0,
length: 8.0,
crossSection: 10.0,
iconName: "battery.100",
colorName: "green",
isWattMode: false,
system: workshopBench,
identifier: "sample.load.charger"
)
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
}
}
#endif

View File

@@ -36,6 +36,38 @@
"system.list.no.components" = "Noch keine Komponenten";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
"sample.system.rv.name" = "Abenteuer-Van";
"sample.system.rv.location" = "12V Wohnstromkreis";
"sample.system.workshop.name" = "Werkbank";
"sample.system.workshop.location" = "Werkzeugecke";
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
"sample.load.compressor.name" = "Luftkompressor";
"sample.load.charger.name" = "Werkzeugladegerät";
"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus";
"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer";
"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot";
"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft";
"system.icon.keywords.ferry" = "fähre, schiff";
"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge";
"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage";
"system.icon.keywords.tent" = "camp, camping, zelt, outdoor";
"system.icon.keywords.solar" = "solar, sonne, pv";
"system.icon.keywords.battery" = "batterie, speicher, akku";
"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum";
"system.icon.keywords.computer" = "computer, elektronik, labor, technik";
"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt";
"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt";
"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei";
"system.icon.keywords.light" = "licht, beleuchtung, lampe";
"system.icon.keywords.bolt" = "strom, power, elektrisch, spannung";
"system.icon.keywords.plug" = "stecker, netzstecker";
"system.icon.keywords.engine" = "motor, generator, antrieb";
"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin";
"system.icon.keywords.water" = "wasser, pumpe, tank";
"system.icon.keywords.heat" = "heizung, heizer, ofen";
"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren";
"system.icon.keywords.climate" = "klima, hvac, temperatur";
// Direct strings
"Systems" = "Systeme";
@@ -44,9 +76,9 @@
"System Name" = "Systemname";
"Create System" = "System erstellen";
"Create your first system" = "Erstelle dein erstes System";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Gib deinem System einen Namen, damit **Cable by VoltPlan** alle zusammengehörenden Verbraucher gruppieren kann.";
"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen.";
"Add your first component" = "Erstelle deine erste Komponente";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Erwecke dein System mit Komponenten zum Leben und überlasse **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen.";
"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Komponenten sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest.";
"Create Component" = "Komponente erstellen";
"Browse Library" = "Bibliothek durchsuchen";
"Browse" = "Durchsuchen";

View File

@@ -36,6 +36,38 @@
"system.list.no.components" = "Aún no hay componentes";
"units.imperial.display" = "Imperial (AWG, ft)";
"units.metric.display" = "Métrico (mm², m)";
"sample.system.rv.name" = "Furgoneta aventura";
"sample.system.rv.location" = "Circuito de vivienda 12V";
"sample.system.workshop.name" = "Banco de taller";
"sample.system.workshop.location" = "Rincón de herramientas";
"sample.load.fridge.name" = "Nevera de compresor";
"sample.load.lighting.name" = "Iluminación LED";
"sample.load.compressor.name" = "Compresor de aire";
"sample.load.charger.name" = "Cargador de herramientas";
"system.icon.keywords.rv" = "autocaravana, camper, caravana, furgo, van";
"system.icon.keywords.truck" = "camión, remolque, tráiler";
"system.icon.keywords.boat" = "barco, embarcación, yate, vela";
"system.icon.keywords.plane" = "avión, vuelo, aire";
"system.icon.keywords.ferry" = "ferry, transbordador, barco";
"system.icon.keywords.house" = "casa, hogar, cabaña, chalet";
"system.icon.keywords.building" = "edificio, oficina, almacén, fábrica, instalación";
"system.icon.keywords.tent" = "camping, tienda, exterior";
"system.icon.keywords.solar" = "solar, sol, fotovoltaico";
"system.icon.keywords.battery" = "batería, almacenamiento, acumulador";
"system.icon.keywords.server" = "servidor, datos, red, rack";
"system.icon.keywords.computer" = "computadora, ordenador, electrónica, laboratorio, tecnología";
"system.icon.keywords.gear" = "engranaje, mecánica, máquina, taller";
"system.icon.keywords.tool" = "herramienta, mantenimiento, reparación, taller";
"system.icon.keywords.hammer" = "martillo, carpintería";
"system.icon.keywords.light" = "luz, iluminación, lámpara";
"system.icon.keywords.bolt" = "volt, energía, eléctrico, potencia";
"system.icon.keywords.plug" = "enchufe, clavija";
"system.icon.keywords.engine" = "motor, generador";
"system.icon.keywords.fuel" = "combustible, diésel, gasolina";
"system.icon.keywords.water" = "agua, bomba, tanque, depósito";
"system.icon.keywords.heat" = "calor, calefacción, horno";
"system.icon.keywords.cold" = "frío, congelar, enfriar";
"system.icon.keywords.climate" = "clima, hvac, temperatura";
// Direct strings
"Systems" = "Sistemas";

View File

@@ -36,6 +36,38 @@
"system.list.no.components" = "Aucun composant pour l'instant";
"units.imperial.display" = "Impérial (AWG, ft)";
"units.metric.display" = "Métrique (mm², m)";
"sample.system.rv.name" = "Van d'aventure";
"sample.system.rv.location" = "Circuit de vie 12 V";
"sample.system.workshop.name" = "Établi d'atelier";
"sample.system.workshop.location" = "Coin outils";
"sample.load.fridge.name" = "Réfrigérateur à compresseur";
"sample.load.lighting.name" = "Éclairage LED";
"sample.load.compressor.name" = "Compresseur d'air";
"sample.load.charger.name" = "Chargeur d'outils";
"system.icon.keywords.rv" = "camping-car, van, fourgon, caravane, motorhome";
"system.icon.keywords.truck" = "camion, remorque, poids lourd";
"system.icon.keywords.boat" = "bateau, marine, yacht, voile";
"system.icon.keywords.plane" = "avion, vol, air";
"system.icon.keywords.ferry" = "ferry, traversier, bateau";
"system.icon.keywords.house" = "maison, foyer, cabane, chalet, lodge";
"system.icon.keywords.building" = "bâtiment, bureau, entrepôt, usine, installation";
"system.icon.keywords.tent" = "camping, tente, plein air";
"system.icon.keywords.solar" = "solaire, soleil";
"system.icon.keywords.battery" = "batterie, stockage, accumulateur";
"system.icon.keywords.server" = "serveur, données, réseau, rack";
"system.icon.keywords.computer" = "ordinateur, électronique, labo, techno";
"system.icon.keywords.gear" = "engrenage, mécanique, machine, atelier";
"system.icon.keywords.tool" = "outil, maintenance, réparation, atelier";
"system.icon.keywords.hammer" = "marteau, charpente";
"system.icon.keywords.light" = "lumière, éclairage, lampe";
"system.icon.keywords.bolt" = "courant, énergie, électrique";
"system.icon.keywords.plug" = "prise, fiche";
"system.icon.keywords.engine" = "moteur, générateur";
"system.icon.keywords.fuel" = "carburant, diesel, essence";
"system.icon.keywords.water" = "eau, pompe, réservoir";
"system.icon.keywords.heat" = "chaleur, chauffage, chaudière, four";
"system.icon.keywords.cold" = "froid, geler, refroidir";
"system.icon.keywords.climate" = "climat, hvac, température";
// Direct strings
"Systems" = "Systèmes";

View File

@@ -36,6 +36,38 @@
"system.list.no.components" = "Nog geen componenten";
"units.imperial.display" = "Imperiaal (AWG, ft)";
"units.metric.display" = "Metrisch (mm², m)";
"sample.system.rv.name" = "Avonturenbus";
"sample.system.rv.location" = "12V leefcircuit";
"sample.system.workshop.name" = "Werkbank";
"sample.system.workshop.location" = "Gereedschapshoek";
"sample.load.fridge.name" = "Koelbox met compressor";
"sample.load.lighting.name" = "LED-strips";
"sample.load.compressor.name" = "Luchtcompressor";
"sample.load.charger.name" = "Gereedschapslader";
"system.icon.keywords.rv" = "camper, kampeerbus, buscamper, mobilhome, campervan";
"system.icon.keywords.truck" = "vrachtwagen, trailer, aanhanger, truck";
"system.icon.keywords.boat" = "boot, schip, jacht, zeil";
"system.icon.keywords.plane" = "vliegtuig, vlucht, lucht";
"system.icon.keywords.ferry" = "veerboot, ferry, schip";
"system.icon.keywords.house" = "huis, thuis, hut, chalet, lodge";
"system.icon.keywords.building" = "gebouw, kantoor, magazijn, fabriek, faciliteit";
"system.icon.keywords.tent" = "kamperen, tent, buiten";
"system.icon.keywords.solar" = "zonne, zon, zonnepaneel";
"system.icon.keywords.battery" = "batterij, opslag, accu";
"system.icon.keywords.server" = "server, data, netwerk, rack";
"system.icon.keywords.computer" = "computer, elektronica, lab, tech";
"system.icon.keywords.gear" = "tandwiel, mechanica, machine, werkplaats";
"system.icon.keywords.tool" = "gereedschap, onderhoud, reparatie, werkplaats";
"system.icon.keywords.hammer" = "hamer, timmerwerk";
"system.icon.keywords.light" = "licht, verlichting, lamp";
"system.icon.keywords.bolt" = "stroom, kracht, elektrisch, spanning";
"system.icon.keywords.plug" = "stekker, aansluiting";
"system.icon.keywords.engine" = "motor, generator";
"system.icon.keywords.fuel" = "brandstof, diesel, benzine";
"system.icon.keywords.water" = "water, pomp, tank, reservoir";
"system.icon.keywords.heat" = "warmte, verwarming, kachel";
"system.icon.keywords.cold" = "koud, vries, koel";
"system.icon.keywords.climate" = "klimaat, hvac, temperatuur";
// Direct strings
"Systems" = "Systemen";

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

@@ -8,44 +8,99 @@
import XCTest
final class CableUITestsScreenshotLaunchTests: XCTestCase {
private func takeScreenshot(name: String,
lifetime: XCTAttachment.Lifetime = .keepAlways) {
let screenshot = XCUIScreen.main.screenshot()
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = name
attachment.lifetime = lifetime
add(attachment)
}
override class var runsForEachTargetApplicationUIConfiguration: Bool {
false
}
override func setUpWithError() throws {
continueAfterFailure = false
}
@MainActor
func testLaunch() throws {
let app = XCUIApplication()
setupSnapshot(app)
app.launch()
// Insert steps here to perform after app launch but before taking a screenshot,
// such as logging into a test account or navigating somewhere in the app
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "Launch Screen"
attachment.lifetime = .keepAlways
add(attachment)
}
@MainActor
func testOnboardingLoadsView() throws {
let app = XCUIApplication()
setupSnapshot(app)
app.launch()
snapshot("0OnboardingSystemsView")
takeScreenshot(name: "01-OnboardingSystemsView")
let createSystemButton = app.buttons["create-system-button"]
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
createSystemButton.tap()
snapshot("1OnboardingLoadsView")
takeScreenshot(name: "02-OnboardingLoadsView")
let libraryCloseButton = app.buttons["library-view-close-button"]
let selectComponentButton = app.buttons["select-component-button"]
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
selectComponentButton.tap()
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
Thread.sleep(forTimeInterval: 10)
takeScreenshot(name: "04-ComponentSelectorView")
libraryCloseButton.tap()
let createComponentButton = app.buttons["create-component-button"]
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
createComponentButton.tap()
snapshot("2LoadEditorView")
takeScreenshot(name: "03-LoadEditorView")
}
func testWithSampleData() throws {
let app = XCUIApplication()
app.launchArguments.append("--uitest-sample-data")
app.launch()
let systemsCollection = app.collectionViews.firstMatch
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
let systemsList: XCUIElement
if collectionExists {
systemsList = systemsCollection
} else {
let table = app.tables.firstMatch
XCTAssertTrue(table.waitForExistence(timeout: 3))
systemsList = table
}
let firstSystemCell = systemsList.cells.element(boundBy: 0)
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
takeScreenshot(name: "05-SystemsWithSampleData")
firstSystemCell.tap()
let loadsCollection = app.collectionViews["loads-list"]
let loadsTable = app.tables["loads-list"]
let loadsElement: XCUIElement
if loadsCollection.waitForExistence(timeout: 3) {
loadsElement = loadsCollection
} else {
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
loadsElement = loadsTable
}
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "06-AdventureVanLoads")
let bomButton = app.buttons["system-bom-button"]
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
bomButton.tap()
let bomView = app.otherElements["system-bom-view"]
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
Thread.sleep(forTimeInterval: 1)
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
}
}

View File

@@ -1,313 +0,0 @@
//
// SnapshotHelper.swift
// Example
//
// Created by Felix Krause on 10/8/15.
//
// -----------------------------------------------------
// IMPORTANT: When modifying this file, make sure to
// increment the version number at the very
// bottom of the file to notify users about
// the new SnapshotHelper.swift
// -----------------------------------------------------
import Foundation
import XCTest
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
} else {
Snapshot.snapshot(name, timeWaitingForIdle: 0)
}
}
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
enum SnapshotError: Error, CustomDebugStringConvertible {
case cannotFindSimulatorHomeDirectory
case cannotRunOnPhysicalDevice
var debugDescription: String {
switch self {
case .cannotFindSimulatorHomeDirectory:
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
case .cannotRunOnPhysicalDevice:
return "Can't use Snapshot on a physical device."
}
}
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
static var cacheDirectory: URL?
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.app = app
Snapshot.waitForAnimations = waitForAnimations
do {
let cacheDir = try getCacheDirectory()
Snapshot.cacheDirectory = cacheDir
setLanguage(app)
setLocale(app)
setLaunchArguments(app)
} catch let error {
NSLog(error.localizedDescription)
}
}
class func setLanguage(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("language.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
} catch {
NSLog("Couldn't detect/set language...")
}
}
class func setLocale(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("locale.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
class func setLaunchArguments(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
do {
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
let results = matches.map { result -> String in
(launchArguments as NSString).substring(with: result.range)
}
app.launchArguments += results
} catch {
NSLog("Couldn't detect/set launch_arguments...")
}
}
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
if timeout > 0 {
waitForLoadingIndicatorToDisappear(within: timeout)
}
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
if Snapshot.waitForAnimations {
sleep(1) // Waiting for the animation to be finished (kind of)
}
#if os(OSX)
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
#else
guard self.app != nil else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
#endif
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
do {
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
let range = NSRange(location: 0, length: simulator.count)
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
} catch let error {
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
NSLog(error.localizedDescription)
}
#endif
}
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
#if os(watchOS)
return image
#else
if #available(iOS 10.0, *) {
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { context in
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
} else {
return image
}
#endif
}
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
#if os(tvOS)
return
#endif
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
}
class func getCacheDirectory() throws -> URL {
let cachePath = "Library/Caches/tools.fastlane"
// on OSX config is stored in /Users/<username>/Library
// and on iOS/tvOS/WatchOS it's in simulator's home dir
#if os(OSX)
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
return homeDir.appendingPathComponent(cachePath)
#elseif arch(i386) || arch(x86_64) || arch(arm64)
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
throw SnapshotError.cannotFindSimulatorHomeDirectory
}
let homeDir = URL(fileURLWithPath: simulatorHostHome)
return homeDir.appendingPathComponent(cachePath)
#else
throw SnapshotError.cannotRunOnPhysicalDevice
#endif
}
}
private extension XCUIElementAttributes {
var isNetworkLoadingIndicator: Bool {
if hasAllowListedIdentifier { return false }
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
}
var hasAllowListedIdentifier: Bool {
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
return allowListedIdentifiers.contains(identifier)
}
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
if elementType == .statusBar { return true }
guard frame.origin == .zero else { return false }
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
}
}
private extension XCUIElementQuery {
var networkLoadingIndicators: XCUIElementQuery {
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isNetworkLoadingIndicator
}
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
}
let deviceWidth = app.windows.firstMatch.frame.width
let isStatusBar = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isStatusBar(deviceWidth)
}
return self.containing(isStatusBar)
}
}
private extension CGFloat {
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
return numberA...numberB ~= self
}
}
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.30]

View File

@@ -1,6 +0,0 @@
app_identifier("app.voltplan.CableApp") # The bundle identifier of your app
# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username
# For more information about the Appfile, see:
# https://docs.fastlane.tools/advanced/#appfile

View File

@@ -1,23 +0,0 @@
# This file contains the fastlane.tools configuration
# You can find the documentation at https://docs.fastlane.tools
#
# For a list of all available actions, check out
#
# https://docs.fastlane.tools/actions
#
# For a list of all available plugins, check out
#
# https://docs.fastlane.tools/plugins/available-plugins
#
# Uncomment the line if you want fastlane to automatically update itself
# update_fastlane
default_platform(:ios)
platform :ios do
desc "Generate new localized screenshots"
lane :screenshots do
capture_screenshots(scheme: "CableScreenshots")
end
end

View File

@@ -1,32 +0,0 @@
fastlane documentation
----
# Installation
Make sure you have the latest version of the Xcode command line tools installed:
```sh
xcode-select --install
```
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
# Available Actions
## iOS
### ios screenshots
```sh
[bundle exec] fastlane ios screenshots
```
Generate new localized screenshots
----
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).

View File

@@ -1,54 +0,0 @@
# Uncomment the lines below you want to change by removing the # in the beginning
devices([
"iPhone 17 Pro",
"iPhone 17 Pro Max"
])
languages([
"en-US",
"de-DE",
"nl-NL",
"es-ES"
])
scheme("CableScreenshots")
clear_previous_screenshots(true)
localize_simulator(true)
erase_simulator(true)
override_status_bar(true)
# A list of devices you want to take the screenshots from
# devices([
# "iPhone 8",
# "iPhone 8 Plus",
# "iPhone SE",
# "iPhone X",
# "iPad Pro (12.9-inch)",
# "iPad Pro (9.7-inch)",
# "Apple TV 1080p",
# "Apple Watch Series 6 - 44mm"
# ])
# languages([
# "en-US",
# "de-DE",
# "it-IT",
# ["pt", "pt_BR"] # Portuguese with Brazilian locale
# ])
# The name of the scheme which contains the UI Tests
# scheme("SchemeName")
# Where should the resulting screenshots be stored?
# output_directory("./screenshots")
# remove the '#' to clear all previously generated screenshots before creating new ones
# clear_previous_screenshots(true)
# Remove the '#' to set the status bar to 9:41 AM, and show full battery and reception. See also override_status_bar_arguments for custom options.
# override_status_bar(true)
# Arguments to pass to the app on launch. See https://docs.fastlane.tools/actions/snapshot/#launch-arguments
# launch_arguments(["-favColor red"])
# For more information about all available options run
# fastlane action snapshot

View File

@@ -1,313 +0,0 @@
//
// SnapshotHelper.swift
// Example
//
// Created by Felix Krause on 10/8/15.
//
// -----------------------------------------------------
// IMPORTANT: When modifying this file, make sure to
// increment the version number at the very
// bottom of the file to notify users about
// the new SnapshotHelper.swift
// -----------------------------------------------------
import Foundation
import XCTest
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
} else {
Snapshot.snapshot(name, timeWaitingForIdle: 0)
}
}
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
enum SnapshotError: Error, CustomDebugStringConvertible {
case cannotFindSimulatorHomeDirectory
case cannotRunOnPhysicalDevice
var debugDescription: String {
switch self {
case .cannotFindSimulatorHomeDirectory:
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
case .cannotRunOnPhysicalDevice:
return "Can't use Snapshot on a physical device."
}
}
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
static var cacheDirectory: URL?
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.app = app
Snapshot.waitForAnimations = waitForAnimations
do {
let cacheDir = try getCacheDirectory()
Snapshot.cacheDirectory = cacheDir
setLanguage(app)
setLocale(app)
setLaunchArguments(app)
} catch let error {
NSLog(error.localizedDescription)
}
}
class func setLanguage(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("language.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
} catch {
NSLog("Couldn't detect/set language...")
}
}
class func setLocale(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("locale.txt")
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
class func setLaunchArguments(_ app: XCUIApplication) {
guard let cacheDirectory = self.cacheDirectory else {
NSLog("CacheDirectory is not set - probably running on a physical device?")
return
}
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
do {
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
let results = matches.map { result -> String in
(launchArguments as NSString).substring(with: result.range)
}
app.launchArguments += results
} catch {
NSLog("Couldn't detect/set launch_arguments...")
}
}
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
if timeout > 0 {
waitForLoadingIndicatorToDisappear(within: timeout)
}
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
if Snapshot.waitForAnimations {
sleep(1) // Waiting for the animation to be finished (kind of)
}
#if os(OSX)
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
#else
guard self.app != nil else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
#endif
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
do {
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
let range = NSRange(location: 0, length: simulator.count)
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
} catch let error {
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
NSLog(error.localizedDescription)
}
#endif
}
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
#if os(watchOS)
return image
#else
if #available(iOS 10.0, *) {
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
return renderer.image { context in
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
}
} else {
return image
}
#endif
}
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
#if os(tvOS)
return
#endif
guard let app = self.app else {
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
return
}
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
}
class func getCacheDirectory() throws -> URL {
let cachePath = "Library/Caches/tools.fastlane"
// on OSX config is stored in /Users/<username>/Library
// and on iOS/tvOS/WatchOS it's in simulator's home dir
#if os(OSX)
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
return homeDir.appendingPathComponent(cachePath)
#elseif arch(i386) || arch(x86_64) || arch(arm64)
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
throw SnapshotError.cannotFindSimulatorHomeDirectory
}
let homeDir = URL(fileURLWithPath: simulatorHostHome)
return homeDir.appendingPathComponent(cachePath)
#else
throw SnapshotError.cannotRunOnPhysicalDevice
#endif
}
}
private extension XCUIElementAttributes {
var isNetworkLoadingIndicator: Bool {
if hasAllowListedIdentifier { return false }
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
}
var hasAllowListedIdentifier: Bool {
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
return allowListedIdentifiers.contains(identifier)
}
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
if elementType == .statusBar { return true }
guard frame.origin == .zero else { return false }
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
}
}
private extension XCUIElementQuery {
var networkLoadingIndicators: XCUIElementQuery {
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isNetworkLoadingIndicator
}
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
}
let deviceWidth = app.windows.firstMatch.frame.width
let isStatusBar = NSPredicate { (evaluatedObject, _) in
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
return element.isStatusBar(deviceWidth)
}
return self.containing(isStatusBar)
}
}
private extension CGFloat {
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
return numberA...numberB ~= self
}
}
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.30]

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="fastlane.lanes">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000145">
</testcase>
<testcase classname="fastlane.lanes" name="1: capture_screenshots" time="392.968167">
</testcase>
</testsuite>
</testsuites>

407
frame_screens.sh Executable file
View File

@@ -0,0 +1,407 @@
#!/bin/bash
set -euo pipefail
FONT_COLOR="#3C3C3C" # color for light text
FONT_BOLD_COLOR="#B51700" # color for bold text
ONLY_IPHONE=false
usage() {
cat <<'EOF'
Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT]
--iphone-only Only frame screenshots whose device slug is not iPad.
SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots)
BG_IMAGE Background image to use (default: Shots/frame-bg.png)
OUT_ROOT Output folder for framed shots (default: Shots/Framed)
EOF
}
POSITIONAL_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--iphone-only)
ONLY_IPHONE=true
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
POSITIONAL_ARGS+=("$@")
break
;;
-*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
*)
POSITIONAL_ARGS+=("$1")
shift
;;
esac
done
if ((${#POSITIONAL_ARGS[@]})); then
set -- "${POSITIONAL_ARGS[@]}"
else
set --
fi
# Inputs
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…)
BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image
OUT_ROOT="${3:-Shots/Framed}" # output folder
FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text
FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
# Tweakables
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
INSET=2 # inset (px) to shave off simulators black edge pixels
SHADOW_OPACITY=0 # 0100
SHADOW_BLUR=20 # blur radius
SHADOW_OFFSET_X=0 # px
SHADOW_OFFSET_Y=40 # px
CANVAS_MARGIN=245 # default margin around the device on the background, px
TITLE_MARGIN=378 # default margin above the device for title text, px
TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px
# Device-specific overrides (can be tuned via env vars)
TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}"
TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}"
TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}"
TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}"
IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}"
IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}"
IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}"
mkdir -p "$OUT_ROOT"
# Function to render mixed-font text (light + semi-bold for *text*)
render_mixed_font_title() {
local canvas="$1"
local title_text="$2"
local title_y="$3"
local output="$4"
local expanded_title
expanded_title="$(printf '%b' "$title_text")"
local temp_img
temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)"
cp "$canvas" "$temp_img"
read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")"
local -a lines=()
while IFS= read -r line || [[ -n "$line" ]]; do
lines+=("$line")
done < <(printf '%s' "$expanded_title")
if ((${#lines[@]} == 0)); then
lines+=("$expanded_title")
fi
if ((${#lines[@]} > 2)); then
lines=("${lines[@]:0:2}")
fi
for idx in "${!lines[@]}"; do
local line="${lines[$idx]}"
local current_y=$((title_y + idx * TITLE_LINE_SPACING))
local -a text_segments=()
local -a font_types=()
local current_text=""
local in_bold=false
local i=0
local line_length=${#line}
while [ $i -lt $line_length ]; do
local char="${line:$i:1}"
if [[ "$char" == "*" ]]; then
text_segments+=("$current_text")
if [[ "$in_bold" == true ]]; then
font_types+=("bold")
else
font_types+=("light")
fi
current_text=""
if [[ "$in_bold" == true ]]; then
in_bold=false
else
in_bold=true
fi
else
current_text+="$char"
fi
i=$((i + 1))
done
text_segments+=("$current_text")
if [[ "$in_bold" == true ]]; then
font_types+=("bold")
else
font_types+=("light")
fi
local total_width=0
for ((j = 0; j < ${#text_segments[@]}; j++)); do
local segment="${text_segments[$j]}"
if [[ -n "$segment" ]]; then
local font_for_measurement="$FONT"
if [[ "${font_types[$j]}" == "bold" ]]; then
font_for_measurement="$FONT_BOLD"
fi
local segment_for_measurement="$segment"
segment_for_measurement="${segment_for_measurement/#/ }"
segment_for_measurement="${segment_for_measurement/%/ }"
local part_width
part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:)
total_width=$((total_width + part_width))
fi
done
if (( total_width <= 0 )); then
continue
fi
local start_x=$(( (canvas_w - total_width) / 2 ))
local x_offset=0
for ((j = 0; j < ${#text_segments[@]}; j++)); do
local segment="${text_segments[$j]}"
if [[ -n "$segment" ]]; then
local font_to_use="$FONT"
local color_to_use="$FONT_COLOR"
if [[ "${font_types[$j]}" == "bold" ]]; then
font_to_use="$FONT_BOLD"
color_to_use="$FONT_BOLD_COLOR"
fi
local segment_for_rendering="$segment"
segment_for_rendering="${segment_for_rendering/#/ }"
segment_for_rendering="${segment_for_rendering/%/ }"
magick "$temp_img" \
-font "$font_to_use" -pointsize 148 -fill "$color_to_use" \
-gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \
"$temp_img"
local text_width
text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:)
x_offset=$((x_offset + text_width))
fi
done
done
cp "$temp_img" "$output"
rm -f "$temp_img"
}
# Function to get title from config file
get_title() {
local lang="$1"
local screenshot_name="$2"
local config_file="./Shots/Titles/${lang}.conf"
# Extract view name from filename format: 03-LoadEditorView_0_5EE662BD-84C7-41AC-806E-EB8C7340A037.png
# Remove .png extension, then extract the part after the first dash and before the first underscore
local base_name=$(basename "$screenshot_name" .png)
# Remove leading number and dash (e.g., "03-")
base_name=${base_name#*-}
# Remove everything from the first underscore onwards (e.g., "_0_5EE662BD...")
base_name=${base_name%%_*}
# Try to find title in config file
if [[ -f "$config_file" ]]; then
local title=$(grep "^${base_name}=" "$config_file" 2>/dev/null | cut -d'=' -f2-)
if [[ -n "$title" ]]; then
echo "$title"
return
fi
fi
# Fallback to default title
echo "***NOT SET***"
}
# Function to frame one screenshot
frame_one () {
local in="$1" # input screenshot (e.g., 1320x2868)
local out="$2" # output image
local bg="$3"
local lang="$4" # language code (e.g., "de", "en")
local screenshot_name="$5" # screenshot filename
local target_width="$6"
local target_height="$7"
local canvas_margin="$8"
local title_margin="$9"
# Read sizes
read -r W H <<<"$(identify -format "%w %h" "$in")"
# Determine corner radius
local R
if [[ "$CORNER_RADIUS" == "auto" ]]; then
# Heuristic: ~1/12 of width works well for iPhone 6.9" (≈110px for 1320px width)
R=$(( W / 12 ))
else
R=$CORNER_RADIUS
fi
# Create rounded-corner mask the same size as the screenshot
local mask
mask="$(mktemp /tmp/mask.XXXXXX_$$.png)"
magick -size "${W}x${H}" xc:black \
-fill white -draw "roundrectangle ${INSET},${INSET},$((W-1-INSET)),$((H-1-INSET)),$R,$R" \
"$mask"
# Apply rounded corners + make a soft drop shadow
# 1) Rounded PNG
local rounded
rounded="$(mktemp /tmp/rounded.XXXXXX_$$.png)"
magick "$in" -alpha set "$mask" -compose copyopacity -composite "$rounded"
# 2) Shadow from rounded image
local shadow
shadow="$(mktemp /tmp/shadow.XXXXXX_$$.png)"
magick "$rounded" \
\( +clone -background black -shadow ${SHADOW_OPACITY}x${SHADOW_BLUR}+${SHADOW_OFFSET_X}+${SHADOW_OFFSET_Y} \) \
+swap -background none -layers merge +repage "$shadow"
# Compose on the background, centered
# First, scale background to be at least screenshot+margin in both dimensions
read -r BW BH <<<"$(identify -format "%w %h" "$bg")"
local minW=$((W + 2*canvas_margin))
local minH=$((H + 2*canvas_margin + title_margin))
local canvas
canvas="$(mktemp /tmp/canvas.XXXXXX_$$.png)"
magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas"
# Add title text above the screenshot
local title_text=$(get_title "$lang" "$screenshot_name")
local with_title
with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)"
# Calculate title position (center horizontally, positioned above the screenshot)
local title_y=$((title_margin - 100)) # 10px from top of title margin
# Render title with mixed fonts
render_mixed_font_title "$canvas" "$title_text" "$title_y" "$with_title"
# Now place shadow (which already includes the rounded image) positioned below the title
# Calculate the vertical offset to center the screenshot in the remaining space below the title
local screenshot_offset=$((title_margin*2))
local temp_result
temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)"
magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result"
# Final step: scale to exact dimensions 1320 × 2868px
magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out"
rm -f "$mask" "$rounded" "$shadow" "$canvas" "$with_title" "$temp_result"
}
# Process all screenshots in SRC_ROOT/*/*.png
resolve_device_profile() {
local device_slug="$1"
PROFILE_BG="$BG_IMAGE"
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_PHONE"
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE"
PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN"
PROFILE_TITLE_MARGIN="$TITLE_MARGIN"
if [[ -n "$device_slug" ]]; then
local slug_lower
slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$slug_lower" == *"ipad"* ]]; then
PROFILE_BG="$IPAD_BG_IMAGE"
PROFILE_TARGET_WIDTH="$TARGET_WIDTH_IPAD"
PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD"
PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN"
PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN"
fi
fi
}
process_lang_dir() {
local lang_path="$1"
local lang="$2"
local device_slug="$3"
local out_dir="$OUT_ROOT"
local log_prefix="$lang"
if [[ -n "$device_slug" ]]; then
out_dir="$out_dir/$device_slug"
log_prefix="$device_slug/$lang"
fi
out_dir="$out_dir/$lang"
mkdir -p "$out_dir"
resolve_device_profile "$device_slug"
shopt -s nullglob
for shot in "$lang_path"/*.png; do
local base="$(basename "$shot")"
frame_one \
"$shot" \
"$out_dir/$base" \
"$PROFILE_BG" \
"$lang" \
"$base" \
"$PROFILE_TARGET_WIDTH" \
"$PROFILE_TARGET_HEIGHT" \
"$PROFILE_CANVAS_MARGIN" \
"$PROFILE_TITLE_MARGIN"
echo "Framed: $log_prefix/$base"
done
}
shopt -s nullglob
found_any=false
skipped_for_device=false
for entry in "$SRC_ROOT"/*; do
[[ -d "$entry" ]] || continue
entry_basename="$(basename "$entry")"
entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')"
if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then
skipped_for_device=true
continue
fi
pattern="${entry%/}/*.png"
if compgen -G "$pattern" > /dev/null; then
process_lang_dir "$entry" "$(basename "$entry")" ""
found_any=true
continue
fi
for langdir in "$entry"/*; do
[[ -d "$langdir" ]] || continue
if [[ "$ONLY_IPHONE" == true ]]; then
lang_device_slug="$(basename "$entry")"
lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')"
if [[ "$lang_slug_lower" == *"ipad"* ]]; then
skipped_for_device=true
continue
fi
fi
pattern="${langdir%/}/*.png"
if compgen -G "$pattern" > /dev/null; then
process_lang_dir "$langdir" "$(basename "$langdir")" "$(basename "$entry")"
found_any=true
fi
done
done
if [[ "$found_any" == false ]]; then
if [[ "$ONLY_IPHONE" == true && "$skipped_for_device" == true ]]; then
echo "No iPhone screenshots found under $SRC_ROOT" >&2
else
echo "No screenshots found under $SRC_ROOT" >&2
fi
fi
echo "Done. Framed images in: $OUT_ROOT/"

101
shooter.sh Executable file
View File

@@ -0,0 +1,101 @@
#!/bin/bash
set -euo pipefail
SCHEME="CableScreenshots"
RESET_SIMULATOR="${RESET_SIMULATOR:-1}"
APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}"
UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}"
is_truthy() {
case "$1" in
1|true|TRUE|yes|YES|on|ON) return 0 ;;
0|false|FALSE|no|NO|off|OFF|"") return 1 ;;
*) return 0 ;;
esac
}
DEVICE_MATRIX=(
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
)
command -v xcparse >/dev/null 2>&1 || {
echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2
exit 1
}
# Resolve a simulator UDID for the given device name and optional OS (e.g., 18.1)
resolve_udid() {
local name="$1"; local os="$2"
if [[ -n "$os" ]]; then
# Prefer Shutdown state for a clean start
xcrun simctl list devices | awk -v n="$name" -v o="$os" -F '[()]' \
'$0 ~ n && $0 ~ o && /Shutdown/ {print $2; exit}'
else
xcrun simctl list devices | awk -v n="$name" -F '[()]' \
'$0 ~ n && /Shutdown/ {print $2; exit}'
fi
}
for device_entry in "${DEVICE_MATRIX[@]}"; do
IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry"
echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ==="
for lang in de fr en es nl; do
echo "Resetting simulator for a clean start..."
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
if [[ -z "$UDID" ]]; then
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
fi
if [[ -z "$UDID" ]]; then
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
fi
xcrun simctl shutdown "$UDID" || true
if is_truthy "$RESET_SIMULATOR"; then
xcrun simctl erase "$UDID"
else
for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do
if [[ -n "$bundle" ]]; then
xcrun simctl terminate "$UDID" "$bundle" || true
xcrun simctl uninstall "$UDID" "$bundle" || true
fi
done
fi
echo "Running screenshots for $lang"
region=$(echo "$lang" | tr '[:lower:]' '[:upper:]')
UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME")
if [[ -z "$UDID" ]]; then
UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}')
fi
if [[ -z "$UDID" ]]; then
echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1
fi
xcrun simctl boot "$UDID" || true
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang"
xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}"
xcrun simctl shutdown "$UDID" || true
xcrun simctl boot "$UDID"
xcrun simctl status_bar booted override \
--time "9:41" \
--batteryState charged --batteryLevel 100 \
--wifiBars 3
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
rm -rf "$bundle" "$outdir"
mkdir -p "$outdir"
xcodebuild test \
-scheme "$SCHEME" \
-destination "id=$UDID" \
-resultBundlePath "$bundle"
xcparse screenshots "$bundle" "$outdir"
echo "Exported screenshots to $outdir"
xcrun simctl shutdown "$UDID" || true
done
done