diff --git a/Cable.xcodeproj/project.pbxproj b/Cable.xcodeproj/project.pbxproj index 539ecb2..b840e20 100644 --- a/Cable.xcodeproj/project.pbxproj +++ b/Cable.xcodeproj/project.pbxproj @@ -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; diff --git a/Cable/AppIcon copy.icon/Assets/body 3.png b/Cable/AppIcon copy.icon/Assets/body 3.png new file mode 100644 index 0000000..965b1ca Binary files /dev/null and b/Cable/AppIcon copy.icon/Assets/body 3.png differ diff --git a/Cable/AppIcon copy.icon/Assets/fuse-top.png b/Cable/AppIcon copy.icon/Assets/fuse-top.png new file mode 100644 index 0000000..780bf2c Binary files /dev/null and b/Cable/AppIcon copy.icon/Assets/fuse-top.png differ diff --git a/Cable/AppIcon copy.icon/Assets/legs 2.png b/Cable/AppIcon copy.icon/Assets/legs 2.png new file mode 100644 index 0000000..d0f41a6 Binary files /dev/null and b/Cable/AppIcon copy.icon/Assets/legs 2.png differ diff --git a/Cable/AppIcon copy.icon/icon.json b/Cable/AppIcon copy.icon/icon.json new file mode 100644 index 0000000..afbed23 --- /dev/null +++ b/Cable/AppIcon copy.icon/icon.json @@ -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" + } +} \ No newline at end of file diff --git a/Cable/AppIcon.icon/Assets/body 3.png b/Cable/AppIcon.icon/Assets/body 3.png new file mode 100644 index 0000000..965b1ca Binary files /dev/null and b/Cable/AppIcon.icon/Assets/body 3.png differ diff --git a/Cable/AppIcon.icon/Assets/box-2.png b/Cable/AppIcon.icon/Assets/box-2.png deleted file mode 100644 index ce339e7..0000000 Binary files a/Cable/AppIcon.icon/Assets/box-2.png and /dev/null differ diff --git a/Cable/AppIcon.icon/Assets/flash.png b/Cable/AppIcon.icon/Assets/flash.png new file mode 100644 index 0000000..10ea651 Binary files /dev/null and b/Cable/AppIcon.icon/Assets/flash.png differ diff --git a/Cable/AppIcon.icon/Assets/fuse-top.png b/Cable/AppIcon.icon/Assets/fuse-top.png new file mode 100644 index 0000000..780bf2c Binary files /dev/null and b/Cable/AppIcon.icon/Assets/fuse-top.png differ diff --git a/Cable/AppIcon.icon/Assets/legs 2.png b/Cable/AppIcon.icon/Assets/legs 2.png new file mode 100644 index 0000000..d0f41a6 Binary files /dev/null and b/Cable/AppIcon.icon/Assets/legs 2.png differ diff --git a/Cable/AppIcon.icon/Assets/voltplan-lines.png b/Cable/AppIcon.icon/Assets/voltplan-lines.png deleted file mode 100644 index adf5169..0000000 Binary files a/Cable/AppIcon.icon/Assets/voltplan-lines.png and /dev/null differ diff --git a/Cable/AppIcon.icon/Assets/voltplan-logo 2 2.png b/Cable/AppIcon.icon/Assets/voltplan-logo 2 2.png deleted file mode 100644 index 6dd9922..0000000 Binary files a/Cable/AppIcon.icon/Assets/voltplan-logo 2 2.png and /dev/null differ diff --git a/Cable/AppIcon.icon/icon.json b/Cable/AppIcon.icon/icon.json index 33fc7f6..a57622e 100644 --- a/Cable/AppIcon.icon/icon.json +++ b/Cable/AppIcon.icon/icon.json @@ -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 diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 3bd65ff..99e266c 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -43,3 +43,27 @@ "sample.load.lighting.name" = "LED strip lighting"; "sample.load.compressor.name" = "Air compressor"; "sample.load.charger.name" = "Tool charger"; +"system.icon.keywords.rv" = "rv, van, camper, motorhome, coach"; +"system.icon.keywords.truck" = "truck, trailer, rig"; +"system.icon.keywords.boat" = "boat, marine, yacht, sail"; +"system.icon.keywords.plane" = "plane, air, flight"; +"system.icon.keywords.ferry" = "ferry, ship"; +"system.icon.keywords.house" = "house, home, cabin, cottage, lodge"; +"system.icon.keywords.building" = "building, office, warehouse, factory, facility"; +"system.icon.keywords.tent" = "camp, tent, outdoor"; +"system.icon.keywords.solar" = "solar, sun"; +"system.icon.keywords.battery" = "battery, storage"; +"system.icon.keywords.server" = "server, data, network, rack"; +"system.icon.keywords.computer" = "computer, electronics, lab, tech"; +"system.icon.keywords.gear" = "gear, mechanic, machine, workshop"; +"system.icon.keywords.tool" = "tool, maintenance, repair, shop"; +"system.icon.keywords.hammer" = "hammer, carpentry"; +"system.icon.keywords.light" = "light, lighting, lamp"; +"system.icon.keywords.bolt" = "bolt, power, electric"; +"system.icon.keywords.plug" = "plug"; +"system.icon.keywords.engine" = "engine, generator, motor"; +"system.icon.keywords.fuel" = "fuel, diesel, gas"; +"system.icon.keywords.water" = "water, pump, tank"; +"system.icon.keywords.heat" = "heat, heater, furnace"; +"system.icon.keywords.cold" = "cold, freeze, cool"; +"system.icon.keywords.climate" = "climate, hvac, temperature"; diff --git a/Cable/ComponentLibraryView.swift b/Cable/ComponentLibraryView.swift index 6463ad0..c5e25c6 100644 --- a/Cable/ComponentLibraryView.swift +++ b/Cable/ComponentLibraryView.swift @@ -9,6 +9,7 @@ struct ComponentLibraryItem: Identifiable, Equatable { let id: String let name: String + let translations: [String: String] let voltageIn: Double? let voltageOut: Double? let watt: Double? @@ -39,10 +40,24 @@ struct ComponentLibraryItem: Identifiable, Equatable { return String(format: "%.1fA", current) } + var localizedName: String { + localizedName(usingPreferredLanguages: Locale.preferredLanguages) ?? name + } + + func localizedName(usingPreferredLanguages languages: [String]) -> String? { + guard let primaryIdentifier = languages.first else { return nil } + let locale = Locale(identifier: primaryIdentifier) + return translation(for: locale) + } + var primaryAffiliateLink: AffiliateLink? { affiliateLink(matching: Locale.current.region?.identifier) } + func localizedName(for locale: Locale) -> String { + translation(for: locale) ?? name + } + func affiliateLink(matching regionCode: String?) -> AffiliateLink? { guard !affiliateLinks.isEmpty else { return nil } @@ -64,6 +79,108 @@ struct ComponentLibraryItem: Identifiable, Equatable { return affiliateLinks.first } + + private func translation(for locale: Locale) -> String? { + guard !translations.isEmpty else { return nil } + + let lookupKeys = ComponentLibraryItem.lookupKeys(for: locale) + + for key in lookupKeys { + if let match = translations[key] { + return match + } + } + + let normalizedTranslations = translations.reduce(into: [String: String]()) { result, element in + let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(element.key) + result[normalizedKey] = element.value + + if let languageOnlyKey = ComponentLibraryItem.languageComponent(fromNormalizedKey: normalizedKey), + result[languageOnlyKey] == nil { + result[languageOnlyKey] = element.value + } + } + + for key in lookupKeys { + let normalizedKey = ComponentLibraryItem.normalizeLocaleKey(key) + if let match = normalizedTranslations[normalizedKey] { + return match + } + } + + return nil + } + + private static func lookupKeys(for locale: Locale) -> [String] { + var keys: [String] = [] + + func append(_ value: String?) { + guard let value, !value.isEmpty else { return } + + for variant in variants(for: value) { + if !keys.contains(variant) { + keys.append(variant) + } + } + } + + append(locale.identifier) + + let components = Locale.components(fromIdentifier: locale.identifier) + + if let language = components[NSLocale.Key.languageCode.rawValue]?.lowercased() { + if let region = components[NSLocale.Key.countryCode.rawValue]?.uppercased() { + append("\(language)_\(region)") + } + append(language) + } + + if let languageCode = locale.language.languageCode?.identifier.lowercased() { + append(languageCode) + } + + if let regionIdentifier = locale.region?.identifier.uppercased(), + let languageIdentifier = locale.language.languageCode?.identifier.lowercased() { + append("\(languageIdentifier)_\(regionIdentifier)") + } + + return keys + } + + private static func normalizeLocaleKey(_ key: String) -> String { + let sanitized = key.replacingOccurrences(of: "-", with: "_") + let parts = sanitized.split(separator: "_", omittingEmptySubsequences: true) + + guard let languagePart = parts.first else { + return sanitized.lowercased() + } + + let language = languagePart.lowercased() + + if parts.count >= 2, let regionPart = parts.last { + return "\(language)_\(regionPart.uppercased())" + } + + return language + } + + private static func languageComponent(fromNormalizedKey key: String) -> String? { + let components = key.split(separator: "_", omittingEmptySubsequences: true) + guard let language = components.first else { return nil } + return String(language) + } + + private static func variants(for key: String) -> [String] { + var collected: [String] = [] + let underscore = key.replacingOccurrences(of: "-", with: "_") + let hyphen = key.replacingOccurrences(of: "_", with: "-") + + for candidate in Set([key, underscore, hyphen]) { + collected.append(candidate) + } + + return collected + } } @MainActor @@ -113,7 +230,7 @@ final class ComponentLibraryViewModel: ObservableObject { components?.queryItems = [ URLQueryItem(name: "filter", value: "(type='load')"), URLQueryItem(name: "sort", value: "+name"), - URLQueryItem(name: "fields", value: "id,collectionId,name,icon,voltage_in,voltage_out,watt"), + URLQueryItem(name: "fields", value: "id,collectionId,name,translations,icon,voltage_in,voltage_out,watt"), URLQueryItem(name: "page", value: "\(page)"), URLQueryItem(name: "perPage", value: "\(perPage)") ] @@ -153,6 +270,7 @@ final class ComponentLibraryViewModel: ObservableObject { ComponentLibraryItem( id: record.id, name: record.name, + translations: record.translations?.flattened ?? [:], voltageIn: record.voltageIn, voltageOut: record.voltageOut, watt: record.watt, @@ -300,6 +418,7 @@ final class ComponentLibraryViewModel: ObservableObject { let id: String let collectionId: String let name: String + let translations: TranslationsContainer? let icon: String? let voltageIn: Double? let voltageOut: Double? @@ -309,11 +428,64 @@ final class ComponentLibraryViewModel: ObservableObject { case id case collectionId case name + case translations case icon case voltageIn = "voltage_in" case voltageOut = "voltage_out" case watt } + + struct TranslationsContainer: Decodable { + private let storage: [String: TranslationValue] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + storage = try container.decode([String: TranslationValue].self) + } + + var flattened: [String: String] { + storage.reduce(into: [:]) { result, entry in + if let value = entry.value.flattened { + result[entry.key] = value + } + } + } + } + + private enum TranslationValue: Decodable { + case string(String) + case dictionary([String: String]) + + init(from decoder: Decoder) throws { + let singleValue = try decoder.singleValueContainer() + if let string = try? singleValue.decode(String.self) { + self = .string(string) + return + } + + if let dictionary = try? singleValue.decode([String: String].self) { + self = .dictionary(dictionary) + return + } + + self = .dictionary([:]) + } + + var flattened: String? { + switch self { + case .string(let value): + return value.isEmpty ? nil : value + case .dictionary(let dictionary): + if let name = dictionary["name"], !name.isEmpty { + return name + } + if let value = dictionary["value"], !value.isEmpty { + return value + } + return dictionary.values.first(where: { !$0.isEmpty }) + } + } + } } private struct AffiliateLinksResponse: Decodable { @@ -396,14 +568,17 @@ struct ComponentLibraryView: View { .padding() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } else { - List(filteredItems) { item in - Button { - onSelect(item) - dismiss() - } label: { - ComponentRow(item: item) + List { + ForEach(filteredItems) { item in + Button { + onSelect(item) + dismiss() + } label: { + ComponentRow(item: item) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) + poweredByVoltplanRow } .listStyle(.insetGrouped) } @@ -414,7 +589,30 @@ struct ComponentLibraryView: View { guard !trimmedQuery.isEmpty else { return viewModel.items } return viewModel.items.filter { item in - item.name.localizedCaseInsensitiveContains(trimmedQuery) + let localizedName = item.localizedName + return localizedName.localizedCaseInsensitiveContains(trimmedQuery) + || item.name.localizedCaseInsensitiveContains(trimmedQuery) + } + } + + @ViewBuilder + private var poweredByVoltplanRow: some View { + if let url = URL(string: "https://voltplan.app") { + Section { + Link(destination: url) { + Image("PoweredByVoltplan") + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(maxWidth: 220) + .padding(.vertical, 20) + .frame(maxWidth: .infinity) + .accessibilityLabel("Powered by Voltplan") + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } + .textCase(nil) } } } @@ -426,7 +624,7 @@ private struct ComponentRow: View { HStack(spacing: 12) { iconView VStack(alignment: .leading, spacing: 4) { - Text(item.name) + Text(item.localizedName) .font(.headline) .foregroundColor(.primary) detailLine diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift index 4945a7f..d22e609 100644 --- a/Cable/LoadsView.swift +++ b/Cable/LoadsView.swift @@ -292,7 +292,8 @@ struct LoadsView: View { } private func addComponent(_ item: ComponentLibraryItem) { - let baseName = item.name.isEmpty ? "Library Load" : item.name + let localizedName = item.localizedName + let baseName = localizedName.isEmpty ? "Library Load" : localizedName let loadName = uniqueLoadName(startingWith: baseName) let voltage = item.displayVoltage ?? 12.0 let power = item.watt ?? (item.current != nil ? item.current! * voltage : 0) diff --git a/Cable/SystemsView.swift b/Cable/SystemsView.swift index e00db4b..5fd80e6 100644 --- a/Cable/SystemsView.swift +++ b/Cable/SystemsView.swift @@ -23,32 +23,34 @@ struct SystemsView: View { "pink", "teal", "indigo", "mint", "cyan", "brown", "gray" ] private let defaultSystemIconName = "building.2" - private let systemIconMappings: [(keywords: [String], icon: String)] = [ - (["rv", "van", "camper", "motorhome", "coach"], "bus"), - (["truck", "trailer", "rig"], "truck.box"), - (["boat", "marine", "yacht", "sail"], "sailboat"), - (["plane", "air", "flight"], "airplane"), - (["ferry", "ship"], "ferry"), - (["house", "home", "cabin", "cottage", "lodge"], "house"), - (["building", "office", "warehouse", "factory", "facility"], "building"), - (["camp", "tent", "outdoor"], "tent"), - (["solar", "sun"], "sun.max"), - (["battery", "storage"], "battery.100"), - (["server", "data", "network", "rack"], "server.rack"), - (["computer", "electronics", "lab", "tech"], "cpu"), - (["gear", "mechanic", "machine", "workshop"], "gear"), - (["tool", "maintenance", "repair", "shop"], "wrench.adjustable"), - (["hammer", "carpentry"], "hammer"), - (["light", "lighting", "lamp"], "lightbulb"), - (["bolt", "power", "electric"], "bolt"), - (["plug"], "powerplug"), - (["engine", "generator", "motor"], "engine.combustion"), - (["fuel", "diesel", "gas"], "fuelpump"), - (["water", "pump", "tank"], "drop"), - (["heat", "heater", "furnace"], "flame"), - (["cold", "freeze", "cool"], "snowflake"), - (["climate", "hvac", "temperature"], "thermometer") - ] + private var systemIconMappings: [(keywords: [String], icon: String)] { + [ + (keywords(for: "system.icon.keywords.rv", fallback: ["rv", "van", "camper", "motorhome", "coach"]), "bus"), + (keywords(for: "system.icon.keywords.truck", fallback: ["truck", "trailer", "rig"]), "truck.box"), + (keywords(for: "system.icon.keywords.boat", fallback: ["boat", "marine", "yacht", "sail"]), "sailboat"), + (keywords(for: "system.icon.keywords.plane", fallback: ["plane", "air", "flight"]), "airplane"), + (keywords(for: "system.icon.keywords.ferry", fallback: ["ferry", "ship"]), "ferry"), + (keywords(for: "system.icon.keywords.house", fallback: ["house", "home", "cabin", "cottage", "lodge"]), "house"), + (keywords(for: "system.icon.keywords.building", fallback: ["building", "office", "warehouse", "factory", "facility"]), "building"), + (keywords(for: "system.icon.keywords.tent", fallback: ["camp", "tent", "outdoor"]), "tent"), + (keywords(for: "system.icon.keywords.solar", fallback: ["solar", "sun"]), "sun.max"), + (keywords(for: "system.icon.keywords.battery", fallback: ["battery", "storage"]), "battery.100"), + (keywords(for: "system.icon.keywords.server", fallback: ["server", "data", "network", "rack"]), "server.rack"), + (keywords(for: "system.icon.keywords.computer", fallback: ["computer", "electronics", "lab", "tech"]), "cpu"), + (keywords(for: "system.icon.keywords.gear", fallback: ["gear", "mechanic", "machine", "workshop"]), "gear"), + (keywords(for: "system.icon.keywords.tool", fallback: ["tool", "maintenance", "repair", "shop"]), "wrench.adjustable"), + (keywords(for: "system.icon.keywords.hammer", fallback: ["hammer", "carpentry"]), "hammer"), + (keywords(for: "system.icon.keywords.light", fallback: ["light", "lighting", "lamp"]), "lightbulb"), + (keywords(for: "system.icon.keywords.bolt", fallback: ["bolt", "power", "electric"]), "bolt"), + (keywords(for: "system.icon.keywords.plug", fallback: ["plug"]), "powerplug"), + (keywords(for: "system.icon.keywords.engine", fallback: ["engine", "generator", "motor"]), "engine.combustion"), + (keywords(for: "system.icon.keywords.fuel", fallback: ["fuel", "diesel", "gas"]), "fuelpump"), + (keywords(for: "system.icon.keywords.water", fallback: ["water", "pump", "tank"]), "drop"), + (keywords(for: "system.icon.keywords.heat", fallback: ["heat", "heater", "furnace"]), "flame"), + (keywords(for: "system.icon.keywords.cold", fallback: ["cold", "freeze", "cool"]), "snowflake"), + (keywords(for: "system.icon.keywords.climate", fallback: ["climate", "hvac", "temperature"]), "thermometer") + ] + } private struct SystemNavigationTarget: Identifiable, Hashable { let id = UUID() @@ -224,7 +226,10 @@ struct SystemsView: View { } private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad { - let baseName = item.name.isEmpty ? String(localized: "default.load.library", comment: "Default name when importing a library load") : item.name + let localizedName = item.localizedName + let baseName = localizedName.isEmpty + ? String(localized: "default.load.library", comment: "Default name when importing a library load") + : localizedName let loadName = uniqueLoadName(for: system, startingWith: baseName) let voltage = item.displayVoltage ?? 12.0 @@ -352,6 +357,32 @@ struct SystemsView: View { return defaultSystemIconName } + private func keywords(for localizationKey: String, fallback: [String]) -> [String] { + let fallbackValue = fallback.joined(separator: ",") + let localizedKeywords = NSLocalizedString( + localizationKey, + tableName: nil, + bundle: .main, + value: fallbackValue, + comment: "" + ) + let separators = CharacterSet(charactersIn: ",;") + let components = localizedKeywords + .components(separatedBy: separators) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + + var uniqueKeywords: [String] = [] + + for keyword in fallback.map({ $0.lowercased() }) + components { + if !uniqueKeywords.contains(keyword) { + uniqueKeywords.append(keyword) + } + } + + return uniqueKeywords + } + private func colorForName(_ colorName: String) -> Color { switch colorName { case "blue": return .blue diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index 921adbb..08328d0 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -44,6 +44,30 @@ "sample.load.lighting.name" = "LED-Streifenbeleuchtung"; "sample.load.compressor.name" = "Luftkompressor"; "sample.load.charger.name" = "Werkzeugladegerät"; +"system.icon.keywords.rv" = "wohnmobil, camper, campervan, van, reisemobil, campingbus"; +"system.icon.keywords.truck" = "lkw, anhänger, zugmaschine, trailer"; +"system.icon.keywords.boat" = "boot, schiff, yacht, segel, segelboot"; +"system.icon.keywords.plane" = "flugzeug, flug, flieger, luft"; +"system.icon.keywords.ferry" = "fähre, schiff"; +"system.icon.keywords.house" = "haus, zuhause, hütte, ferienhaus, lodge"; +"system.icon.keywords.building" = "gebäude, büro, lagerhalle, fabrik, anlage"; +"system.icon.keywords.tent" = "camp, camping, zelt, outdoor"; +"system.icon.keywords.solar" = "solar, sonne, pv"; +"system.icon.keywords.battery" = "batterie, speicher, akku"; +"system.icon.keywords.server" = "server, daten, netzwerk, rack, rechenzentrum"; +"system.icon.keywords.computer" = "computer, elektronik, labor, technik"; +"system.icon.keywords.gear" = "getriebe, mechanik, maschine, werkstatt"; +"system.icon.keywords.tool" = "werkzeug, wartung, reparatur, werkstatt"; +"system.icon.keywords.hammer" = "hammer, tischlerei, zimmerei"; +"system.icon.keywords.light" = "licht, beleuchtung, lampe"; +"system.icon.keywords.bolt" = "strom, power, elektrisch, spannung"; +"system.icon.keywords.plug" = "stecker, netzstecker"; +"system.icon.keywords.engine" = "motor, generator, antrieb"; +"system.icon.keywords.fuel" = "kraftstoff, diesel, benzin"; +"system.icon.keywords.water" = "wasser, pumpe, tank"; +"system.icon.keywords.heat" = "heizung, heizer, ofen"; +"system.icon.keywords.cold" = "kalt, kühlen, eis, gefrieren"; +"system.icon.keywords.climate" = "klima, hvac, temperatur"; // Direct strings "Systems" = "Systeme"; @@ -52,9 +76,9 @@ "System Name" = "Systemname"; "Create System" = "System erstellen"; "Create your first system" = "Erstelle dein erstes System"; -"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Gib deinem System einen Namen, damit **Cable by VoltPlan** alle zusammengehörenden Verbraucher gruppieren kann."; +"Give your setup a name so **Cable by VoltPlan** can organize loads, wiring, and recommendations in one place." = "Ob \"**Mein System**\", \"**Motoryacht LARGO**\" oder \"**Camper Luigi**\". Dein System braucht einen Namen."; "Add your first component" = "Erstelle deine erste Komponente"; -"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Erwecke dein System mit Komponenten zum Leben und überlasse **Cable by VoltPlan** die Kabel- und Sicherungsempfehlungen."; +"Bring your system to life with components and let **Cable by VoltPlan** handle cable and fuse recommendations." = "Komponenten sind das Herz von Cable. Stelle sicher, dass du nie wieder ein zu dünnes Kabel oder die falsche Sicherung verwendest."; "Create Component" = "Komponente erstellen"; "Browse Library" = "Bibliothek durchsuchen"; "Browse" = "Durchsuchen"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index fbe9ea4..aafda08 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -44,6 +44,30 @@ "sample.load.lighting.name" = "Iluminación LED"; "sample.load.compressor.name" = "Compresor de aire"; "sample.load.charger.name" = "Cargador de herramientas"; +"system.icon.keywords.rv" = "autocaravana, camper, caravana, furgo, van"; +"system.icon.keywords.truck" = "camión, remolque, tráiler"; +"system.icon.keywords.boat" = "barco, embarcación, yate, vela"; +"system.icon.keywords.plane" = "avión, vuelo, aire"; +"system.icon.keywords.ferry" = "ferry, transbordador, barco"; +"system.icon.keywords.house" = "casa, hogar, cabaña, chalet"; +"system.icon.keywords.building" = "edificio, oficina, almacén, fábrica, instalación"; +"system.icon.keywords.tent" = "camping, tienda, exterior"; +"system.icon.keywords.solar" = "solar, sol, fotovoltaico"; +"system.icon.keywords.battery" = "batería, almacenamiento, acumulador"; +"system.icon.keywords.server" = "servidor, datos, red, rack"; +"system.icon.keywords.computer" = "computadora, ordenador, electrónica, laboratorio, tecnología"; +"system.icon.keywords.gear" = "engranaje, mecánica, máquina, taller"; +"system.icon.keywords.tool" = "herramienta, mantenimiento, reparación, taller"; +"system.icon.keywords.hammer" = "martillo, carpintería"; +"system.icon.keywords.light" = "luz, iluminación, lámpara"; +"system.icon.keywords.bolt" = "volt, energía, eléctrico, potencia"; +"system.icon.keywords.plug" = "enchufe, clavija"; +"system.icon.keywords.engine" = "motor, generador"; +"system.icon.keywords.fuel" = "combustible, diésel, gasolina"; +"system.icon.keywords.water" = "agua, bomba, tanque, depósito"; +"system.icon.keywords.heat" = "calor, calefacción, horno"; +"system.icon.keywords.cold" = "frío, congelar, enfriar"; +"system.icon.keywords.climate" = "clima, hvac, temperatura"; // Direct strings "Systems" = "Sistemas"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 82b5e91..1ab2cd2 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -44,6 +44,30 @@ "sample.load.lighting.name" = "Éclairage LED"; "sample.load.compressor.name" = "Compresseur d'air"; "sample.load.charger.name" = "Chargeur d'outils"; +"system.icon.keywords.rv" = "camping-car, van, fourgon, caravane, motorhome"; +"system.icon.keywords.truck" = "camion, remorque, poids lourd"; +"system.icon.keywords.boat" = "bateau, marine, yacht, voile"; +"system.icon.keywords.plane" = "avion, vol, air"; +"system.icon.keywords.ferry" = "ferry, traversier, bateau"; +"system.icon.keywords.house" = "maison, foyer, cabane, chalet, lodge"; +"system.icon.keywords.building" = "bâtiment, bureau, entrepôt, usine, installation"; +"system.icon.keywords.tent" = "camping, tente, plein air"; +"system.icon.keywords.solar" = "solaire, soleil"; +"system.icon.keywords.battery" = "batterie, stockage, accumulateur"; +"system.icon.keywords.server" = "serveur, données, réseau, rack"; +"system.icon.keywords.computer" = "ordinateur, électronique, labo, techno"; +"system.icon.keywords.gear" = "engrenage, mécanique, machine, atelier"; +"system.icon.keywords.tool" = "outil, maintenance, réparation, atelier"; +"system.icon.keywords.hammer" = "marteau, charpente"; +"system.icon.keywords.light" = "lumière, éclairage, lampe"; +"system.icon.keywords.bolt" = "courant, énergie, électrique"; +"system.icon.keywords.plug" = "prise, fiche"; +"system.icon.keywords.engine" = "moteur, générateur"; +"system.icon.keywords.fuel" = "carburant, diesel, essence"; +"system.icon.keywords.water" = "eau, pompe, réservoir"; +"system.icon.keywords.heat" = "chaleur, chauffage, chaudière, four"; +"system.icon.keywords.cold" = "froid, geler, refroidir"; +"system.icon.keywords.climate" = "climat, hvac, température"; // Direct strings "Systems" = "Systèmes"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 1f6f6de..3ec1db3 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -44,6 +44,30 @@ "sample.load.lighting.name" = "LED-strips"; "sample.load.compressor.name" = "Luchtcompressor"; "sample.load.charger.name" = "Gereedschapslader"; +"system.icon.keywords.rv" = "camper, kampeerbus, buscamper, mobilhome, campervan"; +"system.icon.keywords.truck" = "vrachtwagen, trailer, aanhanger, truck"; +"system.icon.keywords.boat" = "boot, schip, jacht, zeil"; +"system.icon.keywords.plane" = "vliegtuig, vlucht, lucht"; +"system.icon.keywords.ferry" = "veerboot, ferry, schip"; +"system.icon.keywords.house" = "huis, thuis, hut, chalet, lodge"; +"system.icon.keywords.building" = "gebouw, kantoor, magazijn, fabriek, faciliteit"; +"system.icon.keywords.tent" = "kamperen, tent, buiten"; +"system.icon.keywords.solar" = "zonne, zon, zonnepaneel"; +"system.icon.keywords.battery" = "batterij, opslag, accu"; +"system.icon.keywords.server" = "server, data, netwerk, rack"; +"system.icon.keywords.computer" = "computer, elektronica, lab, tech"; +"system.icon.keywords.gear" = "tandwiel, mechanica, machine, werkplaats"; +"system.icon.keywords.tool" = "gereedschap, onderhoud, reparatie, werkplaats"; +"system.icon.keywords.hammer" = "hamer, timmerwerk"; +"system.icon.keywords.light" = "licht, verlichting, lamp"; +"system.icon.keywords.bolt" = "stroom, kracht, elektrisch, spanning"; +"system.icon.keywords.plug" = "stekker, aansluiting"; +"system.icon.keywords.engine" = "motor, generator"; +"system.icon.keywords.fuel" = "brandstof, diesel, benzine"; +"system.icon.keywords.water" = "water, pomp, tank, reservoir"; +"system.icon.keywords.heat" = "warmte, verwarming, kachel"; +"system.icon.keywords.cold" = "koud, vries, koel"; +"system.icon.keywords.climate" = "klimaat, hvac, temperatuur"; // Direct strings "Systems" = "Systemen"; diff --git a/CableTests/ComponentLibraryItemTests.swift b/CableTests/ComponentLibraryItemTests.swift new file mode 100644 index 0000000..08d780c --- /dev/null +++ b/CableTests/ComponentLibraryItemTests.swift @@ -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") + } +} diff --git a/CableUITestsScreenshot/CableUITestsScreenshot.swift b/CableUITestsScreenshot/CableUITestsScreenshot.swift index 0059e4c..5b3d4cc 100644 --- a/CableUITestsScreenshot/CableUITestsScreenshot.swift +++ b/CableUITestsScreenshot/CableUITestsScreenshot.swift @@ -8,34 +8,90 @@ import XCTest final class CableUITestsScreenshot: XCTestCase { + private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. + try super.setUpWithError() + ensureDoNotDisturbEnabled() + dismissSystemOverlays() } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + try super.tearDownWithError() + dismissSystemOverlays() } @MainActor func testExample() throws { - // UI tests must launch the application that they test. let app = XCUIApplication() app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. + dismissSystemOverlays() } -// @MainActor -// func testLaunchPerformance() throws { -// // This measures how long it takes to launch your application. -// measure(metrics: [XCTApplicationLaunchMetric()]) { -// XCUIApplication().launch() -// } -// } + private func ensureDoNotDisturbEnabled() { + springboard.activate() + let pullStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.02)) + let pullEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.30)) + pullStart.press(forDuration: 0.1, thenDragTo: pullEnd) + + let focusTile = springboard.otherElements["Focus"] + let focusButton = springboard.buttons["Focus"] + if focusTile.waitForExistence(timeout: 2) { + focusTile.press(forDuration: 1.0) + } else if focusButton.waitForExistence(timeout: 2) { + focusButton.press(forDuration: 1.0) + } + + let dndButton = springboard.buttons["Do Not Disturb"] + if dndButton.waitForExistence(timeout: 1) { + if !dndButton.isSelected { + dndButton.tap() + } + } else { + let dndCell = springboard.cells["Do Not Disturb"] + if dndCell.waitForExistence(timeout: 1) && !dndCell.isSelected { + dndCell.tap() + } else { + let dndLabel = springboard.staticTexts["Do Not Disturb"] + if dndLabel.waitForExistence(timeout: 1) { + dndLabel.tap() + } + } + } + + let dismissStart = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.85)) + let dismissEnd = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.15)) + dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd) + } + + private func dismissSystemOverlays() { + let app = XCUIApplication() + let alertButtons = [ + "OK", "Allow", "Later", "Not Now", "Close", + "Continue", "Remind Me Later", "Maybe Later", + ] + + if app.alerts.firstMatch.exists { + handleAlerts(in: app, buttons: alertButtons) + } + + if springboard.alerts.firstMatch.exists || springboard.scrollViews.firstMatch.exists { + handleAlerts(in: springboard, buttons: alertButtons + ["Enable Later"]) + } + } + + private func handleAlerts(in application: XCUIApplication, buttons: [String]) { + for buttonLabel in buttons { + let button = application.buttons[buttonLabel] + if button.waitForExistence(timeout: 0.5) { + button.tap() + return + } + } + let closeButton = application.buttons.matching(NSPredicate(format: "identifier CONTAINS[c] %@", "Close")).firstMatch + if closeButton.exists { + closeButton.tap() + } + } } diff --git a/frame_screens.sh b/frame_screens.sh index 64d79be..e71bdcd 100755 --- a/frame_screens.sh +++ b/frame_screens.sh @@ -1,11 +1,58 @@ #!/bin/bash set -euo pipefail FONT_COLOR="#3C3C3C" # color for light text -FONT_BOLD_COLOR="#B51700" # color for bold texto pipefail +FONT_BOLD_COLOR="#B51700" # color for bold text + +ONLY_IPHONE=false + +usage() { + cat <<'EOF' +Usage: frame_screens.sh [--iphone-only] [SRC_ROOT] [BG_IMAGE] [OUT_ROOT] + + --iphone-only Only frame screenshots whose device slug is not iPad. + SRC_ROOT Root folder with device/lang subfolders (default: Shots/Screenshots) + BG_IMAGE Background image to use (default: Shots/frame-bg.png) + OUT_ROOT Output folder for framed shots (default: Shots/Framed) +EOF +} + +POSITIONAL_ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --iphone-only) + ONLY_IPHONE=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + POSITIONAL_ARGS+=("$@") + break + ;; + -*) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + *) + POSITIONAL_ARGS+=("$1") + shift + ;; + esac +done + +if ((${#POSITIONAL_ARGS[@]})); then + set -- "${POSITIONAL_ARGS[@]}" +else + set -- +fi # Inputs -SRC_ROOT="${1:-Shots/Screenshots}" # root folder with lang subfolders (de/, fr/, en/…) -BG_IMAGE="${2:-Shots/frame-bg.png}" # background image (portrait) +SRC_ROOT="${1:-Shots/Screenshots}" # root folder with device/lang subfolders (iphone-17-pro-max/en/, ipad-pro-13-inch-m4/de/…) +BG_IMAGE="${2:-Shots/frame-bg.png}" # default background image OUT_ROOT="${3:-Shots/Framed}" # output folder FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text @@ -13,12 +60,22 @@ FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text # Tweakables CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width INSET=2 # inset (px) to shave off simulator’s black edge pixels -SHADOW_OPACITY=60 # 0–100 +SHADOW_OPACITY=0 # 0–100 SHADOW_BLUR=20 # blur radius SHADOW_OFFSET_X=0 # px SHADOW_OFFSET_Y=40 # px -CANVAS_MARGIN=190 # margin around the device on the background, px -TITLE_MARGIN=120 # margin above the device for title text, px +CANVAS_MARGIN=245 # default margin around the device on the background, px +TITLE_MARGIN=378 # default margin above the device for title text, px +TITLE_LINE_SPACING=220 # vertical distance between lines of the title text, px + +# Device-specific overrides (can be tuned via env vars) +TARGET_WIDTH_PHONE="${TARGET_WIDTH_PHONE:-1320}" +TARGET_HEIGHT_PHONE="${TARGET_HEIGHT_PHONE:-2868}" +TARGET_WIDTH_IPAD="${TARGET_WIDTH_IPAD:-2048}" +TARGET_HEIGHT_IPAD="${TARGET_HEIGHT_IPAD:-2732}" +IPAD_BG_IMAGE="${IPAD_BG_IMAGE:-$BG_IMAGE}" +IPAD_CANVAS_MARGIN="${IPAD_CANVAS_MARGIN:-240}" +IPAD_TITLE_MARGIN="${IPAD_TITLE_MARGIN:-180}" mkdir -p "$OUT_ROOT" @@ -28,29 +85,43 @@ render_mixed_font_title() { local title_text="$2" local title_y="$3" local output="$4" - - if [[ "$title_text" == *"*"* ]]; then - # Get canvas dimensions - read -r canvas_w canvas_h <<<"$(identify -format "%w %h" "$canvas")" - - # Create a temporary image to measure and render text parts - local temp_img - temp_img="$(mktemp /tmp/text_temp.XXXXXX_$$.png)" - cp "$canvas" "$temp_img" - - # Parse text into segments with their font types - declare -a text_segments=() - declare -a font_types=() - + + 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 - - while [ $i -lt ${#title_text} ]; do - local char="${title_text:$i:1}" - + local line_length=${#line} + + while [ $i -lt $line_length ]; do + local char="${line:$i:1}" if [[ "$char" == "*" ]]; then - # Save current segment (even if empty, to handle cases like "**") text_segments+=("$current_text") if [[ "$in_bold" == true ]]; then font_types+=("bold") @@ -58,7 +129,6 @@ render_mixed_font_title() { font_types+=("light") fi current_text="" - # Toggle bold state if [[ "$in_bold" == true ]]; then in_bold=false else @@ -69,95 +139,62 @@ render_mixed_font_title() { fi i=$((i + 1)) done - - # Handle remaining text - if [[ -n "$current_text" ]]; then - text_segments+=("$current_text") - if [[ "$in_bold" == true ]]; then - font_types+=("bold") - else - font_types+=("light") - fi + + text_segments+=("$current_text") + if [[ "$in_bold" == true ]]; then + font_types+=("bold") + else + font_types+=("light") fi - - # Debug: print segments (remove this later) - echo "DEBUG: Text segments:" - local debug_i=0 - while [ $debug_i -lt ${#text_segments[@]} ]; do - echo " [$debug_i]: '${text_segments[$debug_i]}' (${font_types[$debug_i]})" - debug_i=$((debug_i + 1)) - done - - # Calculate total width + local total_width=0 - local j=0 - while [ $j -lt ${#text_segments[@]} ]; do + for ((j = 0; j < ${#text_segments[@]}; j++)); do local segment="${text_segments[$j]}" - local font_type="${font_types[$j]}" - - # Skip empty segments for width calculation if [[ -n "$segment" ]]; then local font_for_measurement="$FONT" - if [[ "$font_type" == "bold" ]]; then + if [[ "${font_types[$j]}" == "bold" ]]; then font_for_measurement="$FONT_BOLD" fi - - # Replace leading/trailing spaces with non-breaking spaces for measurement local segment_for_measurement="$segment" - segment_for_measurement="${segment_for_measurement/#/ }" # leading space - segment_for_measurement="${segment_for_measurement/%/ }" # trailing space - - local part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:) + segment_for_measurement="${segment_for_measurement/#/ }" + segment_for_measurement="${segment_for_measurement/%/ }" + local part_width + part_width=$(magick -font "$font_for_measurement" -pointsize 148 -size x label:"$segment_for_measurement" -format "%w" info:) total_width=$((total_width + part_width)) fi - j=$((j + 1)) done - - # Calculate starting X position to center the entire text + + if (( total_width <= 0 )); then + continue + fi + local start_x=$(( (canvas_w - total_width) / 2 )) - - # Render each segment local x_offset=0 - j=0 - while [ $j -lt ${#text_segments[@]} ]; do + for ((j = 0; j < ${#text_segments[@]}; j++)); do local segment="${text_segments[$j]}" - local font_type="${font_types[$j]}" - - # Skip empty segments for rendering if [[ -n "$segment" ]]; then local font_to_use="$FONT" local color_to_use="$FONT_COLOR" - if [[ "$font_type" == "bold" ]]; then + if [[ "${font_types[$j]}" == "bold" ]]; then font_to_use="$FONT_BOLD" color_to_use="$FONT_BOLD_COLOR" fi - - # Replace leading/trailing spaces with non-breaking spaces for rendering local segment_for_rendering="$segment" - segment_for_rendering="${segment_for_rendering/#/ }" # leading space - segment_for_rendering="${segment_for_rendering/%/ }" # trailing space - + segment_for_rendering="${segment_for_rendering/#/ }" + segment_for_rendering="${segment_for_rendering/%/ }" magick "$temp_img" \ -font "$font_to_use" -pointsize 148 -fill "$color_to_use" \ - -gravity northwest -annotate "+$((start_x + x_offset))+${title_y}" "$segment_for_rendering" \ + -gravity northwest -annotate "+$((start_x + x_offset))+${current_y}" "$segment_for_rendering" \ "$temp_img" - - # Calculate width of rendered text for next position (use same processed segment) - local text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:) + local text_width + text_width=$(magick -font "$font_to_use" -pointsize 148 -size x label:"$segment_for_rendering" -format "%w" info:) x_offset=$((x_offset + text_width)) fi - j=$((j + 1)) done - - cp "$temp_img" "$output" - rm -f "$temp_img" - else - # No asterisks, simple rendering - magick "$canvas" \ - -font "$FONT" -pointsize 148 -fill "$FONT_COLOR" \ - -gravity north -annotate "+0+${title_y}" "$title_text" \ - "$output" - fi + done + + cp "$temp_img" "$output" + rm -f "$temp_img" } # Function to get title from config file @@ -189,11 +226,15 @@ get_title() { # Function to frame one screenshot frame_one () { - local in="$1" # input screenshot (e.g., 1320x2868) - local out="$2" # output image + local in="$1" # input screenshot (e.g., 1320x2868) + local out="$2" # output image local bg="$3" - local lang="$4" # language code (e.g., "de", "en") - local screenshot_name="$5" # screenshot filename + local lang="$4" # language code (e.g., "de", "en") + local screenshot_name="$5" # screenshot filename + local target_width="$6" + local target_height="$7" + local canvas_margin="$8" + local title_margin="$9" # Read sizes read -r W H <<<"$(identify -format "%w %h" "$in")" @@ -230,8 +271,8 @@ frame_one () { # Compose on the background, centered # First, scale background to be at least screenshot+margin in both dimensions read -r BW BH <<<"$(identify -format "%w %h" "$bg")" - local minW=$((W + 2*CANVAS_MARGIN)) - local minH=$((H + 2*CANVAS_MARGIN + TITLE_MARGIN)) + local minW=$((W + 2*canvas_margin)) + local minH=$((H + 2*canvas_margin + title_margin)) local canvas canvas="$(mktemp /tmp/canvas.XXXXXX_$$.png)" magick "$bg" -resize "${minW}x${minH}^" -gravity center -extent "${minW}x${minH}" "$canvas" @@ -242,35 +283,125 @@ frame_one () { with_title="$(mktemp /tmp/with_title.XXXXXX_$$.png)" # Calculate title position (center horizontally, positioned above the screenshot) - local title_y=$((TITLE_MARGIN - 10)) # 10px from top of title margin + local title_y=$((title_margin - 100)) # 10px from top of title margin # Render title with mixed fonts render_mixed_font_title "$canvas" "$title_text" "$title_y" "$with_title" # Now place shadow (which already includes the rounded image) positioned below the title # Calculate the vertical offset to center the screenshot in the remaining space below the title - local screenshot_offset=$((TITLE_MARGIN*2)) + local screenshot_offset=$((title_margin*2)) local temp_result temp_result="$(mktemp /tmp/temp_result.XXXXXX_$$.png)" magick "$with_title" "$shadow" -gravity center -geometry "+0+${screenshot_offset}" -compose over -composite "$temp_result" # Final step: scale to exact dimensions 1320 × 2868px - magick "$temp_result" -resize "1320x2868^" -gravity center -extent "1320x2868" "$out" + magick "$temp_result" -resize "${target_width}x${target_height}^" -gravity center -extent "${target_width}x${target_height}" "$out" rm -f "$mask" "$rounded" "$shadow" "$canvas" "$with_title" "$temp_result" } # Process all screenshots in SRC_ROOT/*/*.png +resolve_device_profile() { + local device_slug="$1" + + PROFILE_BG="$BG_IMAGE" + PROFILE_TARGET_WIDTH="$TARGET_WIDTH_PHONE" + PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_PHONE" + PROFILE_CANVAS_MARGIN="$CANVAS_MARGIN" + PROFILE_TITLE_MARGIN="$TITLE_MARGIN" + + if [[ -n "$device_slug" ]]; then + local slug_lower + slug_lower="$(printf '%s' "$device_slug" | tr '[:upper:]' '[:lower:]')" + if [[ "$slug_lower" == *"ipad"* ]]; then + PROFILE_BG="$IPAD_BG_IMAGE" + PROFILE_TARGET_WIDTH="$TARGET_WIDTH_IPAD" + PROFILE_TARGET_HEIGHT="$TARGET_HEIGHT_IPAD" + PROFILE_CANVAS_MARGIN="$IPAD_CANVAS_MARGIN" + PROFILE_TITLE_MARGIN="$IPAD_TITLE_MARGIN" + fi + fi +} + +process_lang_dir() { + local lang_path="$1" + local lang="$2" + local device_slug="$3" + + local out_dir="$OUT_ROOT" + local log_prefix="$lang" + + if [[ -n "$device_slug" ]]; then + out_dir="$out_dir/$device_slug" + log_prefix="$device_slug/$lang" + fi + + out_dir="$out_dir/$lang" + mkdir -p "$out_dir" + + resolve_device_profile "$device_slug" + + shopt -s nullglob + for shot in "$lang_path"/*.png; do + local base="$(basename "$shot")" + frame_one \ + "$shot" \ + "$out_dir/$base" \ + "$PROFILE_BG" \ + "$lang" \ + "$base" \ + "$PROFILE_TARGET_WIDTH" \ + "$PROFILE_TARGET_HEIGHT" \ + "$PROFILE_CANVAS_MARGIN" \ + "$PROFILE_TITLE_MARGIN" + echo "Framed: $log_prefix/$base" + done +} + shopt -s nullglob -for langdir in "$SRC_ROOT"/*; do - [[ -d "$langdir" ]] || continue - rel="$(basename "$langdir")" - mkdir -p "$OUT_ROOT/$rel" - for shot in "$langdir"/*.png; do - base="$(basename "$shot")" - frame_one "$shot" "$OUT_ROOT/$rel/$base" "$BG_IMAGE" "$rel" "$base" - echo "Framed: $rel/$base" +found_any=false +skipped_for_device=false +for entry in "$SRC_ROOT"/*; do + [[ -d "$entry" ]] || continue + entry_basename="$(basename "$entry")" + entry_lower="$(printf '%s' "$entry_basename" | tr '[:upper:]' '[:lower:]')" + if [[ "$ONLY_IPHONE" == true && "$entry_lower" == *"ipad"* ]]; then + skipped_for_device=true + continue + fi + + pattern="${entry%/}/*.png" + if compgen -G "$pattern" > /dev/null; then + process_lang_dir "$entry" "$(basename "$entry")" "" + found_any=true + continue + fi + + for langdir in "$entry"/*; do + [[ -d "$langdir" ]] || continue + if [[ "$ONLY_IPHONE" == true ]]; then + lang_device_slug="$(basename "$entry")" + lang_slug_lower="$(printf '%s' "$lang_device_slug" | tr '[:upper:]' '[:lower:]')" + if [[ "$lang_slug_lower" == *"ipad"* ]]; then + skipped_for_device=true + continue + fi + fi + pattern="${langdir%/}/*.png" + if compgen -G "$pattern" > /dev/null; then + process_lang_dir "$langdir" "$(basename "$langdir")" "$(basename "$entry")" + found_any=true + fi done done -echo "Done. Framed images in: $OUT_ROOT/" \ No newline at end of file +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/" diff --git a/shooter.sh b/shooter.sh index 00e41cc..33ed023 100755 --- a/shooter.sh +++ b/shooter.sh @@ -2,8 +2,22 @@ set -euo pipefail SCHEME="CableScreenshots" -DEVICE="iPhone 17 Pro Max" -RUNTIME_OS="26.0" # e.g. "18.1". Leave empty to let Xcode pick. +RESET_SIMULATOR="${RESET_SIMULATOR:-1}" +APP_BUNDLE_ID="${APP_BUNDLE_ID:-app.voltplan.CableApp}" +UITEST_BUNDLE_ID="${UITEST_BUNDLE_ID:-com.yuzuhub.CableUITestsScreenshot}" + +is_truthy() { + case "$1" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + 0|false|FALSE|no|NO|off|OFF|"") return 1 ;; + *) return 0 ;; + esac +} + +DEVICE_MATRIX=( + "iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4" + "iPhone 17 Pro Max|26.0|iphone-17-pro-max" +) command -v xcparse >/dev/null 2>&1 || { echo "xcparse not found. Install with: brew install chargepoint/xcparse/xcparse" >&2 @@ -23,53 +37,65 @@ resolve_udid() { fi } -for lang in de fr en es nl; do - # Erase all content and settings to ensure a clean simulator state - echo "Resetting simulator for a clean start..." - UDID=$(resolve_udid "$DEVICE" "$RUNTIME_OS") - if [[ -z "$UDID" ]]; then - # Fallback: pick any matching (booted or shutdown) - UDID=$(xcrun simctl list devices | awk -v n="$DEVICE" -F '[()]' '$0 ~ n {print $2; exit}') - fi - if [[ -z "$UDID" ]]; then - echo "Could not resolve UDID for $DEVICE" >&2; exit 1 - fi - # Ensure the device is not booted, then fully erase it. Do NOT ignore failures here. - xcrun simctl shutdown "$UDID" || true - xcrun simctl erase "$UDID" - echo "Running screenshots for $lang" - region=$(echo "$lang" | tr '[:lower:]' '[:upper:]') +for device_entry in "${DEVICE_MATRIX[@]}"; do + IFS='|' read -r DEVICE_NAME DEVICE_RUNTIME DEVICE_SLUG <<<"$device_entry" + echo "=== Device: $DEVICE_NAME (runtime: ${DEVICE_RUNTIME:-auto}) ===" - # Resolve simulator UDID and enforce system language/locale on the simulator itself - UDID=$(resolve_udid "$DEVICE" "$RUNTIME_OS") - if [[ -z "$UDID" ]]; then - # Fallback: pick any matching (booted or shutdown) - UDID=$(xcrun simctl list devices | awk -v n="$DEVICE" -F '[()]' '$0 ~ n {print $2; exit}') - fi - if [[ -z "$UDID" ]]; then - echo "Could not resolve UDID for $DEVICE" >&2; exit 1 - fi + for lang in de fr en es nl; do + echo "Resetting simulator for a clean start..." + UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") + if [[ -z "$UDID" ]]; then + UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}') + fi + if [[ -z "$UDID" ]]; then + echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1 + fi - # Boot, set system language & locale, then restart the simulator to ensure it sticks - xcrun simctl boot "$UDID" || true - xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang" - xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}" - # Some versions require a reboot of the sim for language changes to fully apply - xcrun simctl shutdown "$UDID" || true - xcrun simctl boot "$UDID" + xcrun simctl shutdown "$UDID" || true + if is_truthy "$RESET_SIMULATOR"; then + xcrun simctl erase "$UDID" + else + for bundle in "$APP_BUNDLE_ID" "$UITEST_BUNDLE_ID"; do + if [[ -n "$bundle" ]]; then + xcrun simctl terminate "$UDID" "$bundle" || true + xcrun simctl uninstall "$UDID" "$bundle" || true + fi + done + fi + echo "Running screenshots for $lang" + region=$(echo "$lang" | tr '[:lower:]' '[:upper:]') - bundle="results-$lang.xcresult" - outdir="Shots/Screenshots/$lang" - rm -rf "$bundle" "$outdir" - mkdir -p "$outdir" + UDID=$(resolve_udid "$DEVICE_NAME" "$DEVICE_RUNTIME") + if [[ -z "$UDID" ]]; then + UDID=$(xcrun simctl list devices | awk -v n="$DEVICE_NAME" -F '[()]' '$0 ~ n {print $2; exit}') + fi + if [[ -z "$UDID" ]]; then + echo "Could not resolve UDID for $DEVICE_NAME" >&2; exit 1 + fi - # Note: Simulator system language/locale is enforced via simctl (AppleLanguages/AppleLocale) before each run. - xcodebuild test \ - -scheme "$SCHEME" \ - -destination "id=$UDID" \ - -resultBundlePath "$bundle" + xcrun simctl boot "$UDID" || true + xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLanguages -array "$lang" + xcrun simctl spawn "$UDID" defaults write 'Apple Global Domain' AppleLocale "${lang}_${region}" + xcrun simctl shutdown "$UDID" || true + xcrun simctl boot "$UDID" + xcrun simctl status_bar booted override \ + --time "9:41" \ + --batteryState charged --batteryLevel 100 \ + --wifiBars 3 + - xcparse screenshots "$bundle" "$outdir" - echo "Exported screenshots to $outdir" - xcrun simctl shutdown "$UDID" || true -done \ No newline at end of file + 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