automated screenshot generation
This commit is contained in:
2
.bundle/config
Normal file
2
.bundle/config
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
---
|
||||||
|
BUNDLE_PATH: "vendor/bundle"
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
.DS_*
|
.DS_*
|
||||||
fastlane/screenshots
|
fastlane/screenshots
|
||||||
xcshareddata
|
xcshareddata
|
||||||
|
Vendor
|
||||||
|
Shots
|
||||||
|
*.xcresult
|
||||||
1
.ruby-version
Normal file
1
.ruby-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.2.4
|
||||||
@@ -35,3 +35,11 @@
|
|||||||
"system.list.no.components" = "No components yet";
|
"system.list.no.components" = "No components yet";
|
||||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||||
"units.metric.display" = "Metric (mm², m)";
|
"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";
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ struct CableApp: App {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
#if DEBUG
|
||||||
|
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ struct ComponentLibraryView: View {
|
|||||||
Button("Close") {
|
Button("Close") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("library-view-close-button")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ struct ComponentsOnboardingView: View {
|
|||||||
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("select-component-button")
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
386
Cable/LoadsView.swift
Normal file
386
Cable/LoadsView.swift
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
//
|
||||||
|
// 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 baseName = item.name.isEmpty ? "Library Load" : item.name
|
||||||
|
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
91
Cable/SettingsView.swift
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// SettingsView.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Units") {
|
||||||
|
Picker("Unit System", selection: $unitSettings.unitSystem) {
|
||||||
|
ForEach(UnitSystem.allCases, id: \.self) { system in
|
||||||
|
Text(system.displayName).tag(system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Wire Cross-Section:")
|
||||||
|
Spacer()
|
||||||
|
Text(unitSettings.unitSystem.wireAreaUnit)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Length:")
|
||||||
|
Spacer()
|
||||||
|
Text(unitSettings.unitSystem.lengthUnit)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Current Units")
|
||||||
|
} footer: {
|
||||||
|
Text("Changing the unit system will apply to all calculations in the app.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
Text("Safety Disclaimer")
|
||||||
|
.font(.headline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("This application provides electrical calculations for educational and estimation purposes only.")
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
Text("Important:")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("• Always consult qualified electricians for actual installations")
|
||||||
|
Text("• Follow all local electrical codes and regulations")
|
||||||
|
Text("• Electrical work should only be performed by licensed professionals")
|
||||||
|
Text("• These calculations may not account for all environmental factors")
|
||||||
|
Text("• The app developers assume no liability for electrical installations")
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
382
Cable/SystemBillOfMaterialsView.swift
Normal file
382
Cable/SystemBillOfMaterialsView.swift
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
//
|
||||||
|
// SystemBillOfMaterialsView.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SystemBillOfMaterialsView: View {
|
||||||
|
let systemName: String
|
||||||
|
let loads: [SavedLoad]
|
||||||
|
let unitSystem: UnitSystem
|
||||||
|
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
@State private var completedItemIDs: Set<String>
|
||||||
|
@State private var suppressRowTapForID: String?
|
||||||
|
|
||||||
|
private struct Item: Identifiable {
|
||||||
|
enum Destination {
|
||||||
|
case affiliate(URL)
|
||||||
|
case amazonSearch(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
let logicalID: String
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let iconSystemName: String
|
||||||
|
let destination: Destination
|
||||||
|
let isPrimaryComponent: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
init(systemName: String, loads: [SavedLoad], unitSystem: UnitSystem) {
|
||||||
|
self.systemName = systemName
|
||||||
|
self.loads = loads
|
||||||
|
self.unitSystem = unitSystem
|
||||||
|
let initialKeys = loads.flatMap { load in
|
||||||
|
load.bomCompletedItemIDs.map { SystemBillOfMaterialsView.storageKey(for: load, itemID: $0) }
|
||||||
|
}
|
||||||
|
_completedItemIDs = State(initialValue: Set(initialKeys))
|
||||||
|
_suppressRowTapForID = State(initialValue: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if sortedLoads.isEmpty {
|
||||||
|
Section("Components") {
|
||||||
|
Text("No loads saved in this system yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ForEach(sortedLoads) { load in
|
||||||
|
Section(header: sectionHeader(for: load)) {
|
||||||
|
ForEach(items(for: load)) { item in
|
||||||
|
let isCompleted = completedItemIDs.contains(item.id)
|
||||||
|
let destinationURL = destinationURL(for: item.destination, load: load)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
let accessibilityLabel: String = {
|
||||||
|
if isCompleted {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.accessibility.mark.incomplete",
|
||||||
|
comment: "Accessibility label instructing VoiceOver to mark an item incomplete"
|
||||||
|
)
|
||||||
|
return String.localizedStringWithFormat(format, item.title)
|
||||||
|
} else {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.accessibility.mark.complete",
|
||||||
|
comment: "Accessibility label instructing VoiceOver to mark an item complete"
|
||||||
|
)
|
||||||
|
return String.localizedStringWithFormat(format, item.title)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundColor(isCompleted ? .accentColor : .secondary)
|
||||||
|
.imageScale(.large)
|
||||||
|
.onTapGesture {
|
||||||
|
setCompletion(!isCompleted, for: load, item: item)
|
||||||
|
suppressRowTapForID = item.id
|
||||||
|
}
|
||||||
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(item.title)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(isCompleted ? Color.primary.opacity(0.7) : Color.primary)
|
||||||
|
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
|
||||||
|
|
||||||
|
if item.isPrimaryComponent {
|
||||||
|
Text(String(localized: "component.fallback.name", comment: "Tag label marking an item as the component itself"))
|
||||||
|
.font(.caption2.weight(.medium))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Color.accentColor.opacity(0.15), in: Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.detail)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if destinationURL != nil {
|
||||||
|
Image(systemName: "arrow.up.right")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 16))
|
||||||
|
.listRowBackground(
|
||||||
|
Color(.secondarySystemGroupedBackground)
|
||||||
|
)
|
||||||
|
.onTapGesture {
|
||||||
|
if suppressRowTapForID == item.id {
|
||||||
|
suppressRowTapForID = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let destinationURL {
|
||||||
|
openURL(destinationURL)
|
||||||
|
}
|
||||||
|
setCompletion(true, for: load, item: item)
|
||||||
|
suppressRowTapForID = nil
|
||||||
|
suppressRowTapForID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Text(footerMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.navigationTitle(
|
||||||
|
String(
|
||||||
|
format: NSLocalizedString(
|
||||||
|
"bom.navigation.title.system",
|
||||||
|
comment: "Navigation title for the bill of materials view"
|
||||||
|
),
|
||||||
|
locale: Locale.current,
|
||||||
|
systemName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Close") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
refreshCompletedItems()
|
||||||
|
suppressRowTapForID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("system-bom-view")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortedLoads: [SavedLoad] {
|
||||||
|
loads.sorted { lhs, rhs in
|
||||||
|
lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeader(for load: SavedLoad) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
let fallbackTitle = String(localized: "component.fallback.name", comment: "Fallback title for a component that lacks a name")
|
||||||
|
Text(load.name.isEmpty ? fallbackTitle : load.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(dateFormatter.string(from: load.timestamp))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func items(for load: SavedLoad) -> [Item] {
|
||||||
|
let lengthValue: Double
|
||||||
|
if unitSystem == .imperial {
|
||||||
|
lengthValue = load.length * 3.28084
|
||||||
|
} else {
|
||||||
|
lengthValue = load.length
|
||||||
|
}
|
||||||
|
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
||||||
|
|
||||||
|
let crossSectionLabel: String
|
||||||
|
let gaugeQuery: String
|
||||||
|
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is not yet determined")
|
||||||
|
|
||||||
|
if unitSystem == .imperial {
|
||||||
|
let awg = awgFromCrossSection(load.crossSection)
|
||||||
|
if awg > 0 {
|
||||||
|
crossSectionLabel = String(format: "AWG %.0f", awg)
|
||||||
|
gaugeQuery = String(format: "AWG %.0f", awg)
|
||||||
|
} else {
|
||||||
|
crossSectionLabel = unknownSizeLabel
|
||||||
|
gaugeQuery = "battery cable"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if load.crossSection > 0 {
|
||||||
|
crossSectionLabel = String(format: "%.1f mm²", load.crossSection)
|
||||||
|
gaugeQuery = String(format: "%.1f mm2", load.crossSection)
|
||||||
|
} else {
|
||||||
|
crossSectionLabel = unknownSizeLabel
|
||||||
|
gaugeQuery = "battery cable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cableDetail = "\(lengthLabel) • \(crossSectionLabel)"
|
||||||
|
|
||||||
|
let calculatedPower = load.power > 0 ? load.power : load.voltage * load.current
|
||||||
|
let powerDetail = String(format: "%.0f W @ %.1f V", calculatedPower, load.voltage)
|
||||||
|
|
||||||
|
let fuseRating = recommendedFuse(for: load)
|
||||||
|
let fuseDetailFormat = NSLocalizedString(
|
||||||
|
"bom.fuse.detail",
|
||||||
|
comment: "Description for the fuse item in the BOM list"
|
||||||
|
)
|
||||||
|
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
|
||||||
|
|
||||||
|
let cableShoesDetailFormat = NSLocalizedString(
|
||||||
|
"bom.terminals.detail",
|
||||||
|
comment: "Description for the cable terminals item in the BOM list"
|
||||||
|
)
|
||||||
|
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
|
||||||
|
|
||||||
|
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
|
||||||
|
let deviceQuery = load.name.isEmpty
|
||||||
|
? String(format: "DC device %.0fW %.0fV", calculatedPower, load.voltage)
|
||||||
|
: load.name
|
||||||
|
|
||||||
|
let redCableQuery = "\(gaugeQuery) red battery cable"
|
||||||
|
let blackCableQuery = "\(gaugeQuery) black battery cable"
|
||||||
|
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
||||||
|
let terminalQuery = "\(gaugeQuery) cable shoes"
|
||||||
|
|
||||||
|
let items: [Item] = [
|
||||||
|
Item(
|
||||||
|
id: Self.storageKey(for: load, itemID: "component"),
|
||||||
|
logicalID: "component",
|
||||||
|
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
|
||||||
|
detail: powerDetail,
|
||||||
|
iconSystemName: "bolt.fill",
|
||||||
|
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
|
||||||
|
isPrimaryComponent: true
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: Self.storageKey(for: load, itemID: "cable-red"),
|
||||||
|
logicalID: "cable-red",
|
||||||
|
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||||
|
detail: cableDetail,
|
||||||
|
iconSystemName: "bolt.horizontal.circle",
|
||||||
|
destination: .amazonSearch(redCableQuery),
|
||||||
|
isPrimaryComponent: false
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: Self.storageKey(for: load, itemID: "cable-black"),
|
||||||
|
logicalID: "cable-black",
|
||||||
|
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||||
|
detail: cableDetail,
|
||||||
|
iconSystemName: "bolt.horizontal.circle",
|
||||||
|
destination: .amazonSearch(blackCableQuery),
|
||||||
|
isPrimaryComponent: false
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: Self.storageKey(for: load, itemID: "fuse"),
|
||||||
|
logicalID: "fuse",
|
||||||
|
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
|
||||||
|
detail: fuseDetail,
|
||||||
|
iconSystemName: "bolt.shield",
|
||||||
|
destination: .amazonSearch(fuseQuery),
|
||||||
|
isPrimaryComponent: false
|
||||||
|
),
|
||||||
|
Item(
|
||||||
|
id: Self.storageKey(for: load, itemID: "terminals"),
|
||||||
|
logicalID: "terminals",
|
||||||
|
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
|
||||||
|
detail: cableShoesDetail,
|
||||||
|
iconSystemName: "wrench.and.screwdriver",
|
||||||
|
destination: .amazonSearch(terminalQuery),
|
||||||
|
isPrimaryComponent: false
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
private func destinationURL(for destination: Item.Destination, load: SavedLoad) -> URL? {
|
||||||
|
switch destination {
|
||||||
|
case .affiliate(let url):
|
||||||
|
return url
|
||||||
|
case .amazonSearch(let query):
|
||||||
|
let countryCode = load.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||||
|
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storageKey(for load: SavedLoad, itemID: String) -> String {
|
||||||
|
if load.identifier.isEmpty {
|
||||||
|
load.identifier = UUID().uuidString
|
||||||
|
}
|
||||||
|
return "\(load.identifier)::\(itemID)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setCompletion(_ isCompleted: Bool, for load: SavedLoad, item: Item) {
|
||||||
|
if isCompleted {
|
||||||
|
completedItemIDs.insert(item.id)
|
||||||
|
} else {
|
||||||
|
completedItemIDs.remove(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if load.identifier.isEmpty {
|
||||||
|
load.identifier = UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = Set(load.bomCompletedItemIDs)
|
||||||
|
if isCompleted {
|
||||||
|
stored.insert(item.logicalID)
|
||||||
|
} else {
|
||||||
|
stored.remove(item.logicalID)
|
||||||
|
}
|
||||||
|
load.bomCompletedItemIDs = Array(stored).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshCompletedItems() {
|
||||||
|
let keys = loads.flatMap { load in
|
||||||
|
load.bomCompletedItemIDs.map { Self.storageKey(for: load, itemID: $0) }
|
||||||
|
}
|
||||||
|
completedItemIDs = Set(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recommendedFuse(for load: SavedLoad) -> Int {
|
||||||
|
let targetFuse = load.current * 1.25
|
||||||
|
let standardFuses = [1, 2, 3, 5, 7, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 100, 125, 150, 175, 200, 225, 250, 300, 350, 400, 450, 500, 600, 700, 800]
|
||||||
|
return standardFuses.first { $0 >= Int(targetFuse.rounded(.up)) } ?? standardFuses.last ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func awgFromCrossSection(_ crossSectionMM2: Double) -> Double {
|
||||||
|
let mapping: [(awg: Double, area: Double)] = [
|
||||||
|
(20, 0.519), (18, 0.823), (16, 1.31), (14, 2.08), (12, 3.31), (10, 5.26),
|
||||||
|
(8, 8.37), (6, 13.3), (4, 21.2), (2, 33.6), (1, 42.4), (0, 53.5),
|
||||||
|
(00, 67.4), (000, 85.0), (0000, 107.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard crossSectionMM2 > 0 else { return 0 }
|
||||||
|
|
||||||
|
let closest = mapping.min { lhs, rhs in
|
||||||
|
abs(lhs.area - crossSectionMM2) < abs(rhs.area - crossSectionMM2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return closest?.awg ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerMessage: String {
|
||||||
|
NSLocalizedString(
|
||||||
|
"affiliate.disclaimer",
|
||||||
|
comment: "Footer note reminding users that affiliate purchases may support the app"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateFormatter: DateFormatter {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Cable/SystemView.swift
Normal file
40
Cable/SystemView.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// SystemView.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by Stefan Lange-Hegermann on 09.10.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct SystemView: View {
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "square.grid.3x2")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Text("System View")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Text("Coming soon - manage your electrical systems and panels here.")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.navigationTitle("System")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
373
Cable/SystemsView.swift
Normal file
373
Cable/SystemsView.swift
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
//
|
||||||
|
// 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 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 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 baseName = item.name.isEmpty ? String(localized: "default.load.library", comment: "Default name when importing a library load") : item.name
|
||||||
|
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 colorForName(_ colorName: String) -> Color {
|
||||||
|
switch colorName {
|
||||||
|
case "blue": return .blue
|
||||||
|
case "green": return .green
|
||||||
|
case "orange": return .orange
|
||||||
|
case "red": return .red
|
||||||
|
case "purple": return .purple
|
||||||
|
case "yellow": return .yellow
|
||||||
|
case "pink": return .pink
|
||||||
|
case "teal": return .teal
|
||||||
|
case "indigo": return .indigo
|
||||||
|
case "mint": return .mint
|
||||||
|
case "cyan": return .cyan
|
||||||
|
case "brown": return .brown
|
||||||
|
case "gray": return .gray
|
||||||
|
default: return .blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
128
Cable/UITestSampleData.swift
Normal file
128
Cable/UITestSampleData.swift
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
//
|
||||||
|
// UITestSampleData.swift
|
||||||
|
// Cable
|
||||||
|
// Created by Stefan Lange-Hegermann on 06.10.25.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
enum UITestSampleData {
|
||||||
|
static let argument = "--uitest-sample-data"
|
||||||
|
|
||||||
|
static func prepareIfNeeded(container: ModelContainer) {
|
||||||
|
#if DEBUG
|
||||||
|
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
|
||||||
|
|
||||||
|
let context = ModelContext(container)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try clearExistingData(in: context)
|
||||||
|
try seedSampleData(in: context)
|
||||||
|
try context.save()
|
||||||
|
} catch {
|
||||||
|
assertionFailure("Failed to seed UI test sample data: \(error)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private extension UITestSampleData {
|
||||||
|
static func clearExistingData(in context: ModelContext) throws {
|
||||||
|
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
||||||
|
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
||||||
|
let itemDescriptor = FetchDescriptor<Item>()
|
||||||
|
|
||||||
|
let systems = try context.fetch(systemDescriptor)
|
||||||
|
let loads = try context.fetch(loadDescriptor)
|
||||||
|
let items = try context.fetch(itemDescriptor)
|
||||||
|
|
||||||
|
systems.forEach { context.delete($0) }
|
||||||
|
loads.forEach { context.delete($0) }
|
||||||
|
items.forEach { context.delete($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func seedSampleData(in context: ModelContext) throws {
|
||||||
|
let adventureVan = ElectricalSystem(
|
||||||
|
name: String(localized: "sample.system.rv.name", comment: "Sample data name for the adventure van system"),
|
||||||
|
location: String(localized: "sample.system.rv.location", comment: "Sample data location for the adventure van system"),
|
||||||
|
iconName: "bus",
|
||||||
|
colorName: "orange"
|
||||||
|
)
|
||||||
|
adventureVan.timestamp = Date(timeIntervalSinceReferenceDate: 3000)
|
||||||
|
|
||||||
|
let workshopBench = ElectricalSystem(
|
||||||
|
name: String(localized: "sample.system.workshop.name", comment: "Sample data name for the workshop system"),
|
||||||
|
location: String(localized: "sample.system.workshop.location", comment: "Sample data location for the workshop system"),
|
||||||
|
iconName: "wrench.adjustable",
|
||||||
|
colorName: "teal"
|
||||||
|
)
|
||||||
|
workshopBench.timestamp = Date(timeIntervalSinceReferenceDate: 2000)
|
||||||
|
|
||||||
|
context.insert(adventureVan)
|
||||||
|
context.insert(workshopBench)
|
||||||
|
|
||||||
|
let vanFridge = SavedLoad(
|
||||||
|
name: String(localized: "sample.load.fridge.name", comment: "Sample data load name for a compressor fridge"),
|
||||||
|
voltage: 12.0,
|
||||||
|
current: 4.2,
|
||||||
|
power: 50.0,
|
||||||
|
length: 6.0,
|
||||||
|
crossSection: 6.0,
|
||||||
|
iconName: "snowflake",
|
||||||
|
colorName: "blue",
|
||||||
|
isWattMode: true,
|
||||||
|
system: adventureVan,
|
||||||
|
identifier: "sample.load.fridge"
|
||||||
|
)
|
||||||
|
vanFridge.timestamp = Date(timeIntervalSinceReferenceDate: 1100)
|
||||||
|
|
||||||
|
let vanLighting = SavedLoad(
|
||||||
|
name: String(localized: "sample.load.lighting.name", comment: "Sample data load name for LED strip lighting"),
|
||||||
|
voltage: 12.0,
|
||||||
|
current: 2.0,
|
||||||
|
power: 24.0,
|
||||||
|
length: 10.0,
|
||||||
|
crossSection: 2.5,
|
||||||
|
iconName: "lightbulb",
|
||||||
|
colorName: "yellow",
|
||||||
|
isWattMode: false,
|
||||||
|
system: adventureVan,
|
||||||
|
identifier: "sample.load.lighting"
|
||||||
|
)
|
||||||
|
vanLighting.timestamp = Date(timeIntervalSinceReferenceDate: 1200)
|
||||||
|
|
||||||
|
let workshopCompressor = SavedLoad(
|
||||||
|
name: String(localized: "sample.load.compressor.name", comment: "Sample data load name for an air compressor"),
|
||||||
|
voltage: 120.0,
|
||||||
|
current: 8.0,
|
||||||
|
power: 960.0,
|
||||||
|
length: 15.0,
|
||||||
|
crossSection: 16.0,
|
||||||
|
iconName: "hammer",
|
||||||
|
colorName: "red",
|
||||||
|
isWattMode: true,
|
||||||
|
system: workshopBench,
|
||||||
|
identifier: "sample.load.compressor"
|
||||||
|
)
|
||||||
|
workshopCompressor.timestamp = Date(timeIntervalSinceReferenceDate: 2100)
|
||||||
|
|
||||||
|
let workshopCharger = SavedLoad(
|
||||||
|
name: String(localized: "sample.load.charger.name", comment: "Sample data load name for a tool charger"),
|
||||||
|
voltage: 120.0,
|
||||||
|
current: 3.5,
|
||||||
|
power: 420.0,
|
||||||
|
length: 8.0,
|
||||||
|
crossSection: 10.0,
|
||||||
|
iconName: "battery.100",
|
||||||
|
colorName: "green",
|
||||||
|
isWattMode: false,
|
||||||
|
system: workshopBench,
|
||||||
|
identifier: "sample.load.charger"
|
||||||
|
)
|
||||||
|
workshopCharger.timestamp = Date(timeIntervalSinceReferenceDate: 2200)
|
||||||
|
|
||||||
|
[vanFridge, vanLighting, workshopCompressor, workshopCharger].forEach { context.insert($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -36,6 +36,14 @@
|
|||||||
"system.list.no.components" = "Noch keine Komponenten";
|
"system.list.no.components" = "Noch keine Komponenten";
|
||||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||||
"units.metric.display" = "Metrisch (mm², m)";
|
"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";
|
||||||
|
|
||||||
// Direct strings
|
// Direct strings
|
||||||
"Systems" = "Systeme";
|
"Systems" = "Systeme";
|
||||||
|
|||||||
@@ -36,6 +36,14 @@
|
|||||||
"system.list.no.components" = "Aún no hay componentes";
|
"system.list.no.components" = "Aún no hay componentes";
|
||||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||||
"units.metric.display" = "Métrico (mm², m)";
|
"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";
|
||||||
|
|
||||||
// Direct strings
|
// Direct strings
|
||||||
"Systems" = "Sistemas";
|
"Systems" = "Sistemas";
|
||||||
|
|||||||
@@ -36,6 +36,14 @@
|
|||||||
"system.list.no.components" = "Aucun composant pour l'instant";
|
"system.list.no.components" = "Aucun composant pour l'instant";
|
||||||
"units.imperial.display" = "Impérial (AWG, ft)";
|
"units.imperial.display" = "Impérial (AWG, ft)";
|
||||||
"units.metric.display" = "Métrique (mm², m)";
|
"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";
|
||||||
|
|
||||||
// Direct strings
|
// Direct strings
|
||||||
"Systems" = "Systèmes";
|
"Systems" = "Systèmes";
|
||||||
|
|||||||
@@ -36,6 +36,14 @@
|
|||||||
"system.list.no.components" = "Nog geen componenten";
|
"system.list.no.components" = "Nog geen componenten";
|
||||||
"units.imperial.display" = "Imperiaal (AWG, ft)";
|
"units.imperial.display" = "Imperiaal (AWG, ft)";
|
||||||
"units.metric.display" = "Metrisch (mm², m)";
|
"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";
|
||||||
|
|
||||||
// Direct strings
|
// Direct strings
|
||||||
"Systems" = "Systemen";
|
"Systems" = "Systemen";
|
||||||
|
|||||||
@@ -8,44 +8,99 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
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 {
|
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
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
|
@MainActor
|
||||||
func testOnboardingLoadsView() throws {
|
func testOnboardingLoadsView() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
setupSnapshot(app)
|
|
||||||
app.launch()
|
app.launch()
|
||||||
snapshot("0OnboardingSystemsView")
|
takeScreenshot(name: "01-OnboardingSystemsView")
|
||||||
|
|
||||||
let createSystemButton = app.buttons["create-system-button"]
|
let createSystemButton = app.buttons["create-system-button"]
|
||||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||||
createSystemButton.tap()
|
createSystemButton.tap()
|
||||||
|
takeScreenshot(name: "02-OnboardingLoadsView")
|
||||||
snapshot("1OnboardingLoadsView")
|
|
||||||
|
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"]
|
let createComponentButton = app.buttons["create-component-button"]
|
||||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||||
createComponentButton.tap()
|
createComponentButton.tap()
|
||||||
snapshot("2LoadEditorView")
|
takeScreenshot(name: "03-LoadEditorView")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWithSampleData() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments.append("--uitest-sample-data")
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
let systemsCollection = app.collectionViews.firstMatch
|
||||||
|
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
|
||||||
|
|
||||||
|
let systemsList: XCUIElement
|
||||||
|
if collectionExists {
|
||||||
|
systemsList = systemsCollection
|
||||||
|
} else {
|
||||||
|
let table = app.tables.firstMatch
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 3))
|
||||||
|
systemsList = table
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
||||||
|
|
||||||
|
takeScreenshot(name: "05-SystemsWithSampleData")
|
||||||
|
|
||||||
|
firstSystemCell.tap()
|
||||||
|
|
||||||
|
let loadsCollection = app.collectionViews["loads-list"]
|
||||||
|
let loadsTable = app.tables["loads-list"]
|
||||||
|
|
||||||
|
let loadsElement: XCUIElement
|
||||||
|
if loadsCollection.waitForExistence(timeout: 3) {
|
||||||
|
loadsElement = loadsCollection
|
||||||
|
} else {
|
||||||
|
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
|
||||||
|
loadsElement = loadsTable
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
takeScreenshot(name: "06-AdventureVanLoads")
|
||||||
|
|
||||||
|
let bomButton = app.buttons["system-bom-button"]
|
||||||
|
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
||||||
|
bomButton.tap()
|
||||||
|
|
||||||
|
let bomView = app.otherElements["system-bom-view"]
|
||||||
|
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
||||||
|
Thread.sleep(forTimeInterval: 1)
|
||||||
|
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
//
|
|
||||||
// SnapshotHelper.swift
|
|
||||||
// Example
|
|
||||||
//
|
|
||||||
// Created by Felix Krause on 10/8/15.
|
|
||||||
//
|
|
||||||
|
|
||||||
// -----------------------------------------------------
|
|
||||||
// IMPORTANT: When modifying this file, make sure to
|
|
||||||
// increment the version number at the very
|
|
||||||
// bottom of the file to notify users about
|
|
||||||
// the new SnapshotHelper.swift
|
|
||||||
// -----------------------------------------------------
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
|
||||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
|
||||||
if waitForLoadingIndicator {
|
|
||||||
Snapshot.snapshot(name)
|
|
||||||
} else {
|
|
||||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// - Parameters:
|
|
||||||
/// - name: The name of the snapshot
|
|
||||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
|
||||||
@MainActor
|
|
||||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
|
||||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
|
||||||
case cannotFindSimulatorHomeDirectory
|
|
||||||
case cannotRunOnPhysicalDevice
|
|
||||||
|
|
||||||
var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .cannotFindSimulatorHomeDirectory:
|
|
||||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
|
||||||
case .cannotRunOnPhysicalDevice:
|
|
||||||
return "Can't use Snapshot on a physical device."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objcMembers
|
|
||||||
@MainActor
|
|
||||||
open class Snapshot: NSObject {
|
|
||||||
static var app: XCUIApplication?
|
|
||||||
static var waitForAnimations = true
|
|
||||||
static var cacheDirectory: URL?
|
|
||||||
static var screenshotsDirectory: URL? {
|
|
||||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
|
||||||
}
|
|
||||||
static var deviceLanguage = ""
|
|
||||||
static var currentLocale = ""
|
|
||||||
|
|
||||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
|
||||||
|
|
||||||
Snapshot.app = app
|
|
||||||
Snapshot.waitForAnimations = waitForAnimations
|
|
||||||
|
|
||||||
do {
|
|
||||||
let cacheDir = try getCacheDirectory()
|
|
||||||
Snapshot.cacheDirectory = cacheDir
|
|
||||||
setLanguage(app)
|
|
||||||
setLocale(app)
|
|
||||||
setLaunchArguments(app)
|
|
||||||
} catch let error {
|
|
||||||
NSLog(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func setLanguage(_ app: XCUIApplication) {
|
|
||||||
guard let cacheDirectory = self.cacheDirectory else {
|
|
||||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
|
||||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
|
||||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
|
||||||
} catch {
|
|
||||||
NSLog("Couldn't detect/set language...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func setLocale(_ app: XCUIApplication) {
|
|
||||||
guard let cacheDirectory = self.cacheDirectory else {
|
|
||||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
|
||||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
|
||||||
} catch {
|
|
||||||
NSLog("Couldn't detect/set locale...")
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
|
||||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
if !currentLocale.isEmpty {
|
|
||||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
|
||||||
guard let cacheDirectory = self.cacheDirectory else {
|
|
||||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
|
||||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
|
||||||
|
|
||||||
do {
|
|
||||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
|
||||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
|
||||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
|
||||||
let results = matches.map { result -> String in
|
|
||||||
(launchArguments as NSString).substring(with: result.range)
|
|
||||||
}
|
|
||||||
app.launchArguments += results
|
|
||||||
} catch {
|
|
||||||
NSLog("Couldn't detect/set launch_arguments...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
|
||||||
if timeout > 0 {
|
|
||||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
|
||||||
|
|
||||||
if Snapshot.waitForAnimations {
|
|
||||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(OSX)
|
|
||||||
guard let app = self.app else {
|
|
||||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
|
||||||
#else
|
|
||||||
|
|
||||||
guard self.app != nil else {
|
|
||||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenshot = XCUIScreen.main.screenshot()
|
|
||||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
|
||||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
|
||||||
#else
|
|
||||||
let image = screenshot.image
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
|
||||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
|
||||||
let range = NSRange(location: 0, length: simulator.count)
|
|
||||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
|
||||||
|
|
||||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
|
||||||
#if swift(<5.0)
|
|
||||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
|
||||||
#else
|
|
||||||
try image.pngData()?.write(to: path, options: .atomic)
|
|
||||||
#endif
|
|
||||||
} catch let error {
|
|
||||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
|
||||||
NSLog(error.localizedDescription)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
|
||||||
#if os(watchOS)
|
|
||||||
return image
|
|
||||||
#else
|
|
||||||
if #available(iOS 10.0, *) {
|
|
||||||
let format = UIGraphicsImageRendererFormat()
|
|
||||||
format.scale = image.scale
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
|
||||||
return renderer.image { context in
|
|
||||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
|
||||||
#if os(tvOS)
|
|
||||||
return
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard let app = self.app else {
|
|
||||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
|
||||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
|
||||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
class func getCacheDirectory() throws -> URL {
|
|
||||||
let cachePath = "Library/Caches/tools.fastlane"
|
|
||||||
// on OSX config is stored in /Users/<username>/Library
|
|
||||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
|
||||||
#if os(OSX)
|
|
||||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
|
||||||
return homeDir.appendingPathComponent(cachePath)
|
|
||||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
|
||||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
|
||||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
|
||||||
}
|
|
||||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
|
||||||
return homeDir.appendingPathComponent(cachePath)
|
|
||||||
#else
|
|
||||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension XCUIElementAttributes {
|
|
||||||
var isNetworkLoadingIndicator: Bool {
|
|
||||||
if hasAllowListedIdentifier { return false }
|
|
||||||
|
|
||||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
|
||||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
|
||||||
|
|
||||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasAllowListedIdentifier: Bool {
|
|
||||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
|
||||||
|
|
||||||
return allowListedIdentifiers.contains(identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
|
||||||
if elementType == .statusBar { return true }
|
|
||||||
guard frame.origin == .zero else { return false }
|
|
||||||
|
|
||||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
|
||||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
|
||||||
|
|
||||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension XCUIElementQuery {
|
|
||||||
var networkLoadingIndicators: XCUIElementQuery {
|
|
||||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
|
||||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
|
||||||
|
|
||||||
return element.isNetworkLoadingIndicator
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.containing(isNetworkLoadingIndicator)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
var deviceStatusBars: XCUIElementQuery {
|
|
||||||
guard let app = Snapshot.app else {
|
|
||||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
}
|
|
||||||
|
|
||||||
let deviceWidth = app.windows.firstMatch.frame.width
|
|
||||||
|
|
||||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
|
||||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
|
||||||
|
|
||||||
return element.isStatusBar(deviceWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.containing(isStatusBar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension CGFloat {
|
|
||||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
|
||||||
return numberA...numberB ~= self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Please don't remove the lines below
|
|
||||||
// They are used to detect outdated configuration files
|
|
||||||
// SnapshotHelperVersion [1.30]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
app_identifier("app.voltplan.CableApp") # The bundle identifier of your app
|
|
||||||
# apple_id("[[APPLE_ID]]") # Your Apple Developer Portal username
|
|
||||||
|
|
||||||
|
|
||||||
# For more information about the Appfile, see:
|
|
||||||
# https://docs.fastlane.tools/advanced/#appfile
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# This file contains the fastlane.tools configuration
|
|
||||||
# You can find the documentation at https://docs.fastlane.tools
|
|
||||||
#
|
|
||||||
# For a list of all available actions, check out
|
|
||||||
#
|
|
||||||
# https://docs.fastlane.tools/actions
|
|
||||||
#
|
|
||||||
# For a list of all available plugins, check out
|
|
||||||
#
|
|
||||||
# https://docs.fastlane.tools/plugins/available-plugins
|
|
||||||
#
|
|
||||||
|
|
||||||
# Uncomment the line if you want fastlane to automatically update itself
|
|
||||||
# update_fastlane
|
|
||||||
|
|
||||||
default_platform(:ios)
|
|
||||||
|
|
||||||
platform :ios do
|
|
||||||
desc "Generate new localized screenshots"
|
|
||||||
lane :screenshots do
|
|
||||||
capture_screenshots(scheme: "CableScreenshots")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
fastlane documentation
|
|
||||||
----
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
Make sure you have the latest version of the Xcode command line tools installed:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
xcode-select --install
|
|
||||||
```
|
|
||||||
|
|
||||||
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
|
||||||
|
|
||||||
# Available Actions
|
|
||||||
|
|
||||||
## iOS
|
|
||||||
|
|
||||||
### ios screenshots
|
|
||||||
|
|
||||||
```sh
|
|
||||||
[bundle exec] fastlane ios screenshots
|
|
||||||
```
|
|
||||||
|
|
||||||
Generate new localized screenshots
|
|
||||||
|
|
||||||
----
|
|
||||||
|
|
||||||
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
|
||||||
|
|
||||||
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
|
||||||
|
|
||||||
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Uncomment the lines below you want to change by removing the # in the beginning
|
|
||||||
devices([
|
|
||||||
"iPhone 17 Pro",
|
|
||||||
"iPhone 17 Pro Max"
|
|
||||||
])
|
|
||||||
|
|
||||||
languages([
|
|
||||||
"en-US",
|
|
||||||
"de-DE",
|
|
||||||
"nl-NL",
|
|
||||||
"es-ES"
|
|
||||||
])
|
|
||||||
|
|
||||||
scheme("CableScreenshots")
|
|
||||||
clear_previous_screenshots(true)
|
|
||||||
localize_simulator(true)
|
|
||||||
erase_simulator(true)
|
|
||||||
override_status_bar(true)
|
|
||||||
# A list of devices you want to take the screenshots from
|
|
||||||
# devices([
|
|
||||||
# "iPhone 8",
|
|
||||||
# "iPhone 8 Plus",
|
|
||||||
# "iPhone SE",
|
|
||||||
# "iPhone X",
|
|
||||||
# "iPad Pro (12.9-inch)",
|
|
||||||
# "iPad Pro (9.7-inch)",
|
|
||||||
# "Apple TV 1080p",
|
|
||||||
# "Apple Watch Series 6 - 44mm"
|
|
||||||
# ])
|
|
||||||
|
|
||||||
# languages([
|
|
||||||
# "en-US",
|
|
||||||
# "de-DE",
|
|
||||||
# "it-IT",
|
|
||||||
# ["pt", "pt_BR"] # Portuguese with Brazilian locale
|
|
||||||
# ])
|
|
||||||
|
|
||||||
# The name of the scheme which contains the UI Tests
|
|
||||||
# scheme("SchemeName")
|
|
||||||
|
|
||||||
# Where should the resulting screenshots be stored?
|
|
||||||
# output_directory("./screenshots")
|
|
||||||
|
|
||||||
# remove the '#' to clear all previously generated screenshots before creating new ones
|
|
||||||
# clear_previous_screenshots(true)
|
|
||||||
|
|
||||||
# Remove the '#' to set the status bar to 9:41 AM, and show full battery and reception. See also override_status_bar_arguments for custom options.
|
|
||||||
# override_status_bar(true)
|
|
||||||
|
|
||||||
# Arguments to pass to the app on launch. See https://docs.fastlane.tools/actions/snapshot/#launch-arguments
|
|
||||||
# launch_arguments(["-favColor red"])
|
|
||||||
|
|
||||||
# For more information about all available options run
|
|
||||||
# fastlane action snapshot
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
//
|
|
||||||
// SnapshotHelper.swift
|
|
||||||
// Example
|
|
||||||
//
|
|
||||||
// Created by Felix Krause on 10/8/15.
|
|
||||||
//
|
|
||||||
|
|
||||||
// -----------------------------------------------------
|
|
||||||
// IMPORTANT: When modifying this file, make sure to
|
|
||||||
// increment the version number at the very
|
|
||||||
// bottom of the file to notify users about
|
|
||||||
// the new SnapshotHelper.swift
|
|
||||||
// -----------------------------------------------------
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import XCTest
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
|
||||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
|
||||||
if waitForLoadingIndicator {
|
|
||||||
Snapshot.snapshot(name)
|
|
||||||
} else {
|
|
||||||
Snapshot.snapshot(name, timeWaitingForIdle: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// - Parameters:
|
|
||||||
/// - name: The name of the snapshot
|
|
||||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
|
||||||
@MainActor
|
|
||||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
|
||||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SnapshotError: Error, CustomDebugStringConvertible {
|
|
||||||
case cannotFindSimulatorHomeDirectory
|
|
||||||
case cannotRunOnPhysicalDevice
|
|
||||||
|
|
||||||
var debugDescription: String {
|
|
||||||
switch self {
|
|
||||||
case .cannotFindSimulatorHomeDirectory:
|
|
||||||
return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
|
|
||||||
case .cannotRunOnPhysicalDevice:
|
|
||||||
return "Can't use Snapshot on a physical device."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objcMembers
|
|
||||||
@MainActor
|
|
||||||
open class Snapshot: NSObject {
|
|
||||||
static var app: XCUIApplication?
|
|
||||||
static var waitForAnimations = true
|
|
||||||
static var cacheDirectory: URL?
|
|
||||||
static var screenshotsDirectory: URL? {
|
|
||||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
|
||||||
}
|
|
||||||
static var deviceLanguage = ""
|
|
||||||
static var currentLocale = ""
|
|
||||||
|
|
||||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
|
||||||
|
|
||||||
Snapshot.app = app
|
|
||||||
Snapshot.waitForAnimations = waitForAnimations
|
|
||||||
|
|
||||||
do {
|
|
||||||
let cacheDir = try getCacheDirectory()
|
|
||||||
Snapshot.cacheDirectory = cacheDir
|
|
||||||
setLanguage(app)
|
|
||||||
setLocale(app)
|
|
||||||
setLaunchArguments(app)
|
|
||||||
} catch let error {
|
|
||||||
NSLog(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func setLanguage(_ app: XCUIApplication) {
|
|
||||||
guard let cacheDirectory = self.cacheDirectory else {
|
|
||||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = cacheDirectory.appendingPathComponent("language.txt")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
|
||||||
deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
|
||||||
app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
|
|
||||||
} catch {
|
|
||||||
NSLog("Couldn't detect/set language...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func setLocale(_ app: XCUIApplication) {
|
|
||||||
guard let cacheDirectory = self.cacheDirectory else {
|
|
||||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = cacheDirectory.appendingPathComponent("locale.txt")
|
|
||||||
|
|
||||||
do {
|
|
||||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
|
||||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
|
||||||
} catch {
|
|
||||||
NSLog("Couldn't detect/set locale...")
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
|
||||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
if !currentLocale.isEmpty {
|
|
||||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class func setLaunchArguments(_ app: XCUIApplication) {
|
|
||||||
guard let cacheDirectory = self.cacheDirectory else {
|
|
||||||
NSLog("CacheDirectory is not set - probably running on a physical device?")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
|
|
||||||
app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
|
|
||||||
|
|
||||||
do {
|
|
||||||
let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
|
|
||||||
let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
|
|
||||||
let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
|
|
||||||
let results = matches.map { result -> String in
|
|
||||||
(launchArguments as NSString).substring(with: result.range)
|
|
||||||
}
|
|
||||||
app.launchArguments += results
|
|
||||||
} catch {
|
|
||||||
NSLog("Couldn't detect/set launch_arguments...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
|
||||||
if timeout > 0 {
|
|
||||||
waitForLoadingIndicatorToDisappear(within: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
|
|
||||||
|
|
||||||
if Snapshot.waitForAnimations {
|
|
||||||
sleep(1) // Waiting for the animation to be finished (kind of)
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(OSX)
|
|
||||||
guard let app = self.app else {
|
|
||||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
|
|
||||||
#else
|
|
||||||
|
|
||||||
guard self.app != nil else {
|
|
||||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let screenshot = XCUIScreen.main.screenshot()
|
|
||||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
|
||||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
|
||||||
#else
|
|
||||||
let image = screenshot.image
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
// The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
|
|
||||||
let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
|
|
||||||
let range = NSRange(location: 0, length: simulator.count)
|
|
||||||
simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
|
|
||||||
|
|
||||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
|
||||||
#if swift(<5.0)
|
|
||||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
|
||||||
#else
|
|
||||||
try image.pngData()?.write(to: path, options: .atomic)
|
|
||||||
#endif
|
|
||||||
} catch let error {
|
|
||||||
NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
|
|
||||||
NSLog(error.localizedDescription)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
class func fixLandscapeOrientation(image: UIImage) -> UIImage {
|
|
||||||
#if os(watchOS)
|
|
||||||
return image
|
|
||||||
#else
|
|
||||||
if #available(iOS 10.0, *) {
|
|
||||||
let format = UIGraphicsImageRendererFormat()
|
|
||||||
format.scale = image.scale
|
|
||||||
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
|
|
||||||
return renderer.image { context in
|
|
||||||
image.draw(in: CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
|
|
||||||
#if os(tvOS)
|
|
||||||
return
|
|
||||||
#endif
|
|
||||||
|
|
||||||
guard let app = self.app else {
|
|
||||||
NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
|
|
||||||
let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
|
|
||||||
_ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
class func getCacheDirectory() throws -> URL {
|
|
||||||
let cachePath = "Library/Caches/tools.fastlane"
|
|
||||||
// on OSX config is stored in /Users/<username>/Library
|
|
||||||
// and on iOS/tvOS/WatchOS it's in simulator's home dir
|
|
||||||
#if os(OSX)
|
|
||||||
let homeDir = URL(fileURLWithPath: NSHomeDirectory())
|
|
||||||
return homeDir.appendingPathComponent(cachePath)
|
|
||||||
#elseif arch(i386) || arch(x86_64) || arch(arm64)
|
|
||||||
guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
|
|
||||||
throw SnapshotError.cannotFindSimulatorHomeDirectory
|
|
||||||
}
|
|
||||||
let homeDir = URL(fileURLWithPath: simulatorHostHome)
|
|
||||||
return homeDir.appendingPathComponent(cachePath)
|
|
||||||
#else
|
|
||||||
throw SnapshotError.cannotRunOnPhysicalDevice
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension XCUIElementAttributes {
|
|
||||||
var isNetworkLoadingIndicator: Bool {
|
|
||||||
if hasAllowListedIdentifier { return false }
|
|
||||||
|
|
||||||
let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
|
|
||||||
let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
|
|
||||||
|
|
||||||
return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasAllowListedIdentifier: Bool {
|
|
||||||
let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
|
|
||||||
|
|
||||||
return allowListedIdentifiers.contains(identifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
|
|
||||||
if elementType == .statusBar { return true }
|
|
||||||
guard frame.origin == .zero else { return false }
|
|
||||||
|
|
||||||
let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
|
|
||||||
let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
|
|
||||||
|
|
||||||
return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension XCUIElementQuery {
|
|
||||||
var networkLoadingIndicators: XCUIElementQuery {
|
|
||||||
let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
|
|
||||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
|
||||||
|
|
||||||
return element.isNetworkLoadingIndicator
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.containing(isNetworkLoadingIndicator)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
var deviceStatusBars: XCUIElementQuery {
|
|
||||||
guard let app = Snapshot.app else {
|
|
||||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
|
||||||
}
|
|
||||||
|
|
||||||
let deviceWidth = app.windows.firstMatch.frame.width
|
|
||||||
|
|
||||||
let isStatusBar = NSPredicate { (evaluatedObject, _) in
|
|
||||||
guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
|
|
||||||
|
|
||||||
return element.isStatusBar(deviceWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
return self.containing(isStatusBar)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension CGFloat {
|
|
||||||
func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
|
|
||||||
return numberA...numberB ~= self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Please don't remove the lines below
|
|
||||||
// They are used to detect outdated configuration files
|
|
||||||
// SnapshotHelperVersion [1.30]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<testsuites>
|
|
||||||
<testsuite name="fastlane.lanes">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000145">
|
|
||||||
|
|
||||||
</testcase>
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: capture_screenshots" time="392.968167">
|
|
||||||
|
|
||||||
</testcase>
|
|
||||||
|
|
||||||
</testsuite>
|
|
||||||
</testsuites>
|
|
||||||
276
frame_screens.sh
Executable file
276
frame_screens.sh
Executable file
@@ -0,0 +1,276 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
FONT_COLOR="#3C3C3C" # color for light text
|
||||||
|
FONT_BOLD_COLOR="#B51700" # color for bold texto pipefail
|
||||||
|
|
||||||
|
# Inputs
|
||||||
|
SRC_ROOT="${1:-Shots/Screenshots}" # root folder with lang subfolders (de/, fr/, en/…)
|
||||||
|
BG_IMAGE="${2:-Shots/frame-bg.png}" # background image (portrait)
|
||||||
|
OUT_ROOT="${3:-Shots/Framed}" # output folder
|
||||||
|
FONT="./Shots/Fonts/Oswald-Light.ttf" # font for title text
|
||||||
|
FONT_BOLD="./Shots/Fonts/Oswald-SemiBold.ttf" # font for *emphasized* text
|
||||||
|
|
||||||
|
# Tweakables
|
||||||
|
CORNER_RADIUS="auto" # corner radius; "auto" picks a good value based on width
|
||||||
|
INSET=2 # inset (px) to shave off simulator’s black edge pixels
|
||||||
|
SHADOW_OPACITY=60 # 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
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
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 current_text=""
|
||||||
|
local in_bold=false
|
||||||
|
local i=0
|
||||||
|
|
||||||
|
while [ $i -lt ${#title_text} ]; do
|
||||||
|
local char="${title_text:$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")
|
||||||
|
else
|
||||||
|
font_types+=("light")
|
||||||
|
fi
|
||||||
|
current_text=""
|
||||||
|
# Toggle bold state
|
||||||
|
if [[ "$in_bold" == true ]]; then
|
||||||
|
in_bold=false
|
||||||
|
else
|
||||||
|
in_bold=true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
current_text+="$char"
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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:)
|
||||||
|
total_width=$((total_width + part_width))
|
||||||
|
fi
|
||||||
|
j=$((j + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
# Calculate starting X position to center the entire text
|
||||||
|
local start_x=$(( (canvas_w - total_width) / 2 ))
|
||||||
|
|
||||||
|
# Render each segment
|
||||||
|
local x_offset=0
|
||||||
|
j=0
|
||||||
|
while [ $j -lt ${#text_segments[@]} ]; 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
|
||||||
|
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
|
||||||
|
|
||||||
|
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" \
|
||||||
|
"$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:)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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 - 10)) # 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 "1320x2868^" -gravity center -extent "1320x2868" "$out"
|
||||||
|
|
||||||
|
rm -f "$mask" "$rounded" "$shadow" "$canvas" "$with_title" "$temp_result"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process all screenshots in SRC_ROOT/*/*.png
|
||||||
|
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"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Done. Framed images in: $OUT_ROOT/"
|
||||||
75
shooter.sh
Executable file
75
shooter.sh
Executable file
@@ -0,0 +1,75 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCHEME="CableScreenshots"
|
||||||
|
DEVICE="iPhone 17 Pro Max"
|
||||||
|
RUNTIME_OS="26.0" # e.g. "18.1". Leave empty to let Xcode pick.
|
||||||
|
|
||||||
|
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 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:]')
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
bundle="results-$lang.xcresult"
|
||||||
|
outdir="Shots/Screenshots/$lang"
|
||||||
|
rm -rf "$bundle" "$outdir"
|
||||||
|
mkdir -p "$outdir"
|
||||||
|
|
||||||
|
# Note: Simulator system language/locale is enforced via simctl (AppleLanguages/AppleLocale) before each run.
|
||||||
|
xcodebuild test \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-destination "id=$UDID" \
|
||||||
|
-resultBundlePath "$bundle"
|
||||||
|
|
||||||
|
xcparse screenshots "$bundle" "$outdir"
|
||||||
|
echo "Exported screenshots to $outdir"
|
||||||
|
xcrun simctl shutdown "$UDID" || true
|
||||||
|
done
|
||||||
Reference in New Issue
Block a user