From dd13178f0e84d12d9baf7f441972d00cc88ff864 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Mon, 13 Oct 2025 09:38:22 +0200 Subject: [PATCH] automated screenshot generation --- .bundle/config | 2 + .gitignore | 5 +- .ruby-version | 1 + Cable/Base.lproj/Localizable.strings | 8 + Cable/CableApp.swift | 6 + Cable/ComponentLibraryView.swift | 1 + Cable/ComponentsOnboardingView.swift | 1 + Cable/ContentView.swift | 1222 ----------------- Cable/LoadsView.swift | 386 ++++++ Cable/SettingsView.swift | 91 ++ Cable/SystemBillOfMaterialsView.swift | 382 ++++++ Cable/SystemView.swift | 40 + Cable/SystemsView.swift | 373 +++++ Cable/UITestSampleData.swift | 128 ++ Cable/de.lproj/Localizable.strings | 8 + Cable/es.lproj/Localizable.strings | 8 + Cable/fr.lproj/Localizable.strings | 8 + Cable/nl.lproj/Localizable.strings | 8 + .../CableUITestsScreenshotLaunchTests.swift | 99 +- CableUITestsScreenshot/SnapshotHelper.swift | 313 ----- fastlane/Appfile | 6 - fastlane/Fastfile | 23 - fastlane/README.md | 32 - fastlane/Snapfile | 54 - fastlane/SnapshotHelper.swift | 313 ----- fastlane/report.xml | 18 - frame_screens.sh | 276 ++++ shooter.sh | 75 + 28 files changed, 1883 insertions(+), 2004 deletions(-) create mode 100644 .bundle/config create mode 100644 .ruby-version create mode 100644 Cable/LoadsView.swift create mode 100644 Cable/SettingsView.swift create mode 100644 Cable/SystemBillOfMaterialsView.swift create mode 100644 Cable/SystemView.swift create mode 100644 Cable/SystemsView.swift create mode 100644 Cable/UITestSampleData.swift delete mode 100644 CableUITestsScreenshot/SnapshotHelper.swift delete mode 100644 fastlane/Appfile delete mode 100644 fastlane/Fastfile delete mode 100644 fastlane/README.md delete mode 100644 fastlane/Snapfile delete mode 100644 fastlane/SnapshotHelper.swift delete mode 100644 fastlane/report.xml create mode 100755 frame_screens.sh create mode 100755 shooter.sh diff --git a/.bundle/config b/.bundle/config new file mode 100644 index 0000000..2369228 --- /dev/null +++ b/.bundle/config @@ -0,0 +1,2 @@ +--- +BUNDLE_PATH: "vendor/bundle" diff --git a/.gitignore b/.gitignore index 5f2166c..feaf141 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .DS_* fastlane/screenshots -xcshareddata \ No newline at end of file +xcshareddata +Vendor +Shots +*.xcresult \ No newline at end of file diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..351227f --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.2.4 diff --git a/Cable/Base.lproj/Localizable.strings b/Cable/Base.lproj/Localizable.strings index 85a205d..3bd65ff 100644 --- a/Cable/Base.lproj/Localizable.strings +++ b/Cable/Base.lproj/Localizable.strings @@ -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"; diff --git a/Cable/CableApp.swift b/Cable/CableApp.swift index fce2b8f..38b210c 100644 --- a/Cable/CableApp.swift +++ b/Cable/CableApp.swift @@ -30,6 +30,12 @@ struct CableApp: App { } }() + init() { +#if DEBUG + UITestSampleData.prepareIfNeeded(container: sharedModelContainer) +#endif + } + var body: some Scene { WindowGroup { ContentView() diff --git a/Cable/ComponentLibraryView.swift b/Cable/ComponentLibraryView.swift index a569d32..6463ad0 100644 --- a/Cable/ComponentLibraryView.swift +++ b/Cable/ComponentLibraryView.swift @@ -346,6 +346,7 @@ struct ComponentLibraryView: View { Button("Close") { dismiss() } + .accessibilityIdentifier("library-view-close-button") } } } diff --git a/Cable/ComponentsOnboardingView.swift b/Cable/ComponentsOnboardingView.swift index 9cd12f2..46aecf1 100644 --- a/Cable/ComponentsOnboardingView.swift +++ b/Cable/ComponentsOnboardingView.swift @@ -79,6 +79,7 @@ struct ComponentsOnboardingView: View { .stroke(Color.blue.opacity(0.24), lineWidth: 1) ) } + .accessibilityIdentifier("select-component-button") .buttonStyle(.plain) } .padding(.horizontal, 24) diff --git a/Cable/ContentView.swift b/Cable/ContentView.swift index 3f9df20..6d01fc1 100644 --- a/Cable/ContentView.swift +++ b/Cable/ContentView.swift @@ -7,1228 +7,6 @@ struct ContentView: View { } } -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) - } - } - } - .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() - 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() - 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 - } - } -} - -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) - } - } - } - .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") - } - } - 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! - } -} - - - - -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") - } - } -} - -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() - } - } - } - } - } -} - -private 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 - @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 - } - } - } - - 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 - } -} - - #Preview { ContentView() .modelContainer(for: Item.self, inMemory: true) diff --git a/Cable/LoadsView.swift b/Cable/LoadsView.swift new file mode 100644 index 0000000..4945a7f --- /dev/null +++ b/Cable/LoadsView.swift @@ -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! + } +} diff --git a/Cable/SettingsView.swift b/Cable/SettingsView.swift new file mode 100644 index 0000000..fc9fddb --- /dev/null +++ b/Cable/SettingsView.swift @@ -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() + } + } + } + } + } +} \ No newline at end of file diff --git a/Cable/SystemBillOfMaterialsView.swift b/Cable/SystemBillOfMaterialsView.swift new file mode 100644 index 0000000..e840d6c --- /dev/null +++ b/Cable/SystemBillOfMaterialsView.swift @@ -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 + @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 + } +} diff --git a/Cable/SystemView.swift b/Cable/SystemView.swift new file mode 100644 index 0000000..ae225ca --- /dev/null +++ b/Cable/SystemView.swift @@ -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") + } + } +} \ No newline at end of file diff --git a/Cable/SystemsView.swift b/Cable/SystemsView.swift new file mode 100644 index 0000000..e00db4b --- /dev/null +++ b/Cable/SystemsView.swift @@ -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() + 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() + 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 + } + } +} diff --git a/Cable/UITestSampleData.swift b/Cable/UITestSampleData.swift new file mode 100644 index 0000000..27e9bf3 --- /dev/null +++ b/Cable/UITestSampleData.swift @@ -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() + let loadDescriptor = FetchDescriptor() + let itemDescriptor = FetchDescriptor() + + 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 diff --git a/Cable/de.lproj/Localizable.strings b/Cable/de.lproj/Localizable.strings index a536478..921adbb 100644 --- a/Cable/de.lproj/Localizable.strings +++ b/Cable/de.lproj/Localizable.strings @@ -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"; diff --git a/Cable/es.lproj/Localizable.strings b/Cable/es.lproj/Localizable.strings index 23e5dae..fbe9ea4 100644 --- a/Cable/es.lproj/Localizable.strings +++ b/Cable/es.lproj/Localizable.strings @@ -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"; diff --git a/Cable/fr.lproj/Localizable.strings b/Cable/fr.lproj/Localizable.strings index 20ef4ec..82b5e91 100644 --- a/Cable/fr.lproj/Localizable.strings +++ b/Cable/fr.lproj/Localizable.strings @@ -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"; diff --git a/Cable/nl.lproj/Localizable.strings b/Cable/nl.lproj/Localizable.strings index 7570116..1f6f6de 100644 --- a/Cable/nl.lproj/Localizable.strings +++ b/Cable/nl.lproj/Localizable.strings @@ -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"; diff --git a/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift b/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift index d7ac1cf..bf2800c 100644 --- a/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift +++ b/CableUITestsScreenshot/CableUITestsScreenshotLaunchTests.swift @@ -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") } } diff --git a/CableUITestsScreenshot/SnapshotHelper.swift b/CableUITestsScreenshot/SnapshotHelper.swift deleted file mode 100644 index 6dec130..0000000 --- a/CableUITestsScreenshot/SnapshotHelper.swift +++ /dev/null @@ -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//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] diff --git a/fastlane/Appfile b/fastlane/Appfile deleted file mode 100644 index d131970..0000000 --- a/fastlane/Appfile +++ /dev/null @@ -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 diff --git a/fastlane/Fastfile b/fastlane/Fastfile deleted file mode 100644 index af8bfe7..0000000 --- a/fastlane/Fastfile +++ /dev/null @@ -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 diff --git a/fastlane/README.md b/fastlane/README.md deleted file mode 100644 index a0e96bc..0000000 --- a/fastlane/README.md +++ /dev/null @@ -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). diff --git a/fastlane/Snapfile b/fastlane/Snapfile deleted file mode 100644 index 69f7495..0000000 --- a/fastlane/Snapfile +++ /dev/null @@ -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 diff --git a/fastlane/SnapshotHelper.swift b/fastlane/SnapshotHelper.swift deleted file mode 100644 index 6dec130..0000000 --- a/fastlane/SnapshotHelper.swift +++ /dev/null @@ -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//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] diff --git a/fastlane/report.xml b/fastlane/report.xml deleted file mode 100644 index ca3e709..0000000 --- a/fastlane/report.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frame_screens.sh b/frame_screens.sh new file mode 100755 index 0000000..64d79be --- /dev/null +++ b/frame_screens.sh @@ -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/" \ No newline at end of file diff --git a/shooter.sh b/shooter.sh new file mode 100755 index 0000000..00e41cc --- /dev/null +++ b/shooter.sh @@ -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 \ No newline at end of file