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_*
|
||||
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";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Metric (mm², m)";
|
||||
"sample.system.rv.name" = "Adventure Van";
|
||||
"sample.system.rv.location" = "12V living circuit";
|
||||
"sample.system.workshop.name" = "Workshop Bench";
|
||||
"sample.system.workshop.location" = "Tool corner";
|
||||
"sample.load.fridge.name" = "Compressor fridge";
|
||||
"sample.load.lighting.name" = "LED strip lighting";
|
||||
"sample.load.compressor.name" = "Air compressor";
|
||||
"sample.load.charger.name" = "Tool charger";
|
||||
|
||||
@@ -30,6 +30,12 @@ struct CableApp: App {
|
||||
}
|
||||
}()
|
||||
|
||||
init() {
|
||||
#if DEBUG
|
||||
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -346,6 +346,7 @@ struct ComponentLibraryView: View {
|
||||
Button("Close") {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier("library-view-close-button")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ struct ComponentsOnboardingView: View {
|
||||
.stroke(Color.blue.opacity(0.24), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.accessibilityIdentifier("select-component-button")
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Metrisch (mm², m)";
|
||||
"sample.system.rv.name" = "Abenteuer-Van";
|
||||
"sample.system.rv.location" = "12V Wohnstromkreis";
|
||||
"sample.system.workshop.name" = "Werkbank";
|
||||
"sample.system.workshop.location" = "Werkzeugecke";
|
||||
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
||||
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
|
||||
"sample.load.compressor.name" = "Luftkompressor";
|
||||
"sample.load.charger.name" = "Werkzeugladegerät";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systeme";
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"system.list.no.components" = "Aún no hay componentes";
|
||||
"units.imperial.display" = "Imperial (AWG, ft)";
|
||||
"units.metric.display" = "Métrico (mm², m)";
|
||||
"sample.system.rv.name" = "Furgoneta aventura";
|
||||
"sample.system.rv.location" = "Circuito de vivienda 12V";
|
||||
"sample.system.workshop.name" = "Banco de taller";
|
||||
"sample.system.workshop.location" = "Rincón de herramientas";
|
||||
"sample.load.fridge.name" = "Nevera de compresor";
|
||||
"sample.load.lighting.name" = "Iluminación LED";
|
||||
"sample.load.compressor.name" = "Compresor de aire";
|
||||
"sample.load.charger.name" = "Cargador de herramientas";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Sistemas";
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"system.list.no.components" = "Aucun composant pour l'instant";
|
||||
"units.imperial.display" = "Impérial (AWG, ft)";
|
||||
"units.metric.display" = "Métrique (mm², m)";
|
||||
"sample.system.rv.name" = "Van d'aventure";
|
||||
"sample.system.rv.location" = "Circuit de vie 12 V";
|
||||
"sample.system.workshop.name" = "Établi d'atelier";
|
||||
"sample.system.workshop.location" = "Coin outils";
|
||||
"sample.load.fridge.name" = "Réfrigérateur à compresseur";
|
||||
"sample.load.lighting.name" = "Éclairage LED";
|
||||
"sample.load.compressor.name" = "Compresseur d'air";
|
||||
"sample.load.charger.name" = "Chargeur d'outils";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systèmes";
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"system.list.no.components" = "Nog geen componenten";
|
||||
"units.imperial.display" = "Imperiaal (AWG, ft)";
|
||||
"units.metric.display" = "Metrisch (mm², m)";
|
||||
"sample.system.rv.name" = "Avonturenbus";
|
||||
"sample.system.rv.location" = "12V leefcircuit";
|
||||
"sample.system.workshop.name" = "Werkbank";
|
||||
"sample.system.workshop.location" = "Gereedschapshoek";
|
||||
"sample.load.fridge.name" = "Koelbox met compressor";
|
||||
"sample.load.lighting.name" = "LED-strips";
|
||||
"sample.load.compressor.name" = "Luchtcompressor";
|
||||
"sample.load.charger.name" = "Gereedschapslader";
|
||||
|
||||
// Direct strings
|
||||
"Systems" = "Systemen";
|
||||
|
||||
@@ -8,44 +8,99 @@
|
||||
import XCTest
|
||||
|
||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||
|
||||
|
||||
|
||||
private func takeScreenshot(name: String,
|
||||
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
let attachment = XCTAttachment(screenshot: screenshot)
|
||||
attachment.name = name
|
||||
attachment.lifetime = lifetime
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func testOnboardingLoadsView() throws {
|
||||
let app = XCUIApplication()
|
||||
setupSnapshot(app)
|
||||
|
||||
app.launch()
|
||||
snapshot("0OnboardingSystemsView")
|
||||
takeScreenshot(name: "01-OnboardingSystemsView")
|
||||
|
||||
let createSystemButton = app.buttons["create-system-button"]
|
||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||
createSystemButton.tap()
|
||||
|
||||
snapshot("1OnboardingLoadsView")
|
||||
takeScreenshot(name: "02-OnboardingLoadsView")
|
||||
|
||||
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||
let selectComponentButton = app.buttons["select-component-button"]
|
||||
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
|
||||
selectComponentButton.tap()
|
||||
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
||||
Thread.sleep(forTimeInterval: 10)
|
||||
takeScreenshot(name: "04-ComponentSelectorView")
|
||||
libraryCloseButton.tap()
|
||||
|
||||
let createComponentButton = app.buttons["create-component-button"]
|
||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||
createComponentButton.tap()
|
||||
snapshot("2LoadEditorView")
|
||||
takeScreenshot(name: "03-LoadEditorView")
|
||||
}
|
||||
|
||||
func testWithSampleData() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments.append("--uitest-sample-data")
|
||||
app.launch()
|
||||
|
||||
let systemsCollection = app.collectionViews.firstMatch
|
||||
let collectionExists = systemsCollection.waitForExistence(timeout: 3)
|
||||
|
||||
let systemsList: XCUIElement
|
||||
if collectionExists {
|
||||
systemsList = systemsCollection
|
||||
} else {
|
||||
let table = app.tables.firstMatch
|
||||
XCTAssertTrue(table.waitForExistence(timeout: 3))
|
||||
systemsList = table
|
||||
}
|
||||
|
||||
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
||||
|
||||
takeScreenshot(name: "05-SystemsWithSampleData")
|
||||
|
||||
firstSystemCell.tap()
|
||||
|
||||
let loadsCollection = app.collectionViews["loads-list"]
|
||||
let loadsTable = app.tables["loads-list"]
|
||||
|
||||
let loadsElement: XCUIElement
|
||||
if loadsCollection.waitForExistence(timeout: 3) {
|
||||
loadsElement = loadsCollection
|
||||
} else {
|
||||
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
|
||||
loadsElement = loadsTable
|
||||
}
|
||||
|
||||
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
takeScreenshot(name: "06-AdventureVanLoads")
|
||||
|
||||
let bomButton = app.buttons["system-bom-button"]
|
||||
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
||||
bomButton.tap()
|
||||
|
||||
let bomView = app.otherElements["system-bom-view"]
|
||||
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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