Compare commits
3 Commits
97a9d3903c
...
b11d627fdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b11d627fdb | ||
|
|
ced06f9eb6 | ||
|
|
5fcc33529a |
@@ -45,11 +45,21 @@
|
|||||||
);
|
);
|
||||||
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
target = 3E5C0BCB2E72C0FD00247EC8 /* Cable */;
|
||||||
};
|
};
|
||||||
|
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
CableUITestsScreenshotLaunchTests.swift,
|
||||||
|
);
|
||||||
|
target = 3E37F6542E93FB6F00836187 /* CableUITestsScreenshot */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
3EB0C1772EBBAF8F007BAFC4 /* Exceptions for "CableUITestsScreenshot" folder in "CableUITestsScreenshot" target */,
|
||||||
|
);
|
||||||
path = CableUITestsScreenshot;
|
path = CableUITestsScreenshot;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -113,6 +123,7 @@
|
|||||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||||
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
3E5C0BCD2E72C0FD00247EC8 /* Products */,
|
||||||
|
57738E9B07763CFA62681EEE /* Pods */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -127,6 +138,13 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
57738E9B07763CFA62681EEE /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
);
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -147,8 +165,6 @@
|
|||||||
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
3E37F6562E93FB6F00836187 /* CableUITestsScreenshot */,
|
||||||
);
|
);
|
||||||
name = CableUITestsScreenshot;
|
name = CableUITestsScreenshot;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CableUITestsScreenshot;
|
productName = CableUITestsScreenshot;
|
||||||
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
|
productReference = 3E37F6552E93FB6F00836187 /* CableUITestsScreenshot.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
@@ -169,8 +185,6 @@
|
|||||||
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
3E5C0BCE2E72C0FD00247EC8 /* Cable */,
|
||||||
);
|
);
|
||||||
name = Cable;
|
name = Cable;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = Cable;
|
productName = Cable;
|
||||||
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
|
productReference = 3E5C0BCC2E72C0FD00247EC8 /* Cable.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
@@ -192,8 +206,6 @@
|
|||||||
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
3E5C0BE02E72C0FE00247EC8 /* CableTests */,
|
||||||
);
|
);
|
||||||
name = CableTests;
|
name = CableTests;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CableTests;
|
productName = CableTests;
|
||||||
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
|
productReference = 3E5C0BDD2E72C0FE00247EC8 /* CableTests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
@@ -215,8 +227,6 @@
|
|||||||
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
3E5C0BEA2E72C0FE00247EC8 /* CableUITests */,
|
||||||
);
|
);
|
||||||
name = CableUITests;
|
name = CableUITests;
|
||||||
packageProductDependencies = (
|
|
||||||
);
|
|
||||||
productName = CableUITests;
|
productName = CableUITests;
|
||||||
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
|
productReference = 3E5C0BE72E72C0FE00247EC8 /* CableUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
@@ -405,10 +415,11 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 39;
|
||||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Cable/Info.plist;
|
INFOPLIST_FILE = Cable/Info.plist;
|
||||||
@@ -440,10 +451,11 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Cable/Cable.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 34;
|
CURRENT_PROJECT_VERSION = 39;
|
||||||
DEVELOPMENT_TEAM = RE4FXQ754N;
|
DEVELOPMENT_TEAM = RE4FXQ754N;
|
||||||
ENABLE_APP_SANDBOX = YES;
|
ENABLE_APP_SANDBOX = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = Cable/Info.plist;
|
INFOPLIST_FILE = Cable/Info.plist;
|
||||||
|
|||||||
7
Cable.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Cable.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Cable.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
36
Cable/AppDelegate.swift
Normal file
36
Cable/AppDelegate.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// AppDelegate.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by Stefan Lange-Hegermann on 01.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
|
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
|
AnalyticsTracker.configure()
|
||||||
|
NSLog("Launched")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnalyticsTracker {
|
||||||
|
static func configure() {}
|
||||||
|
|
||||||
|
static func log(_ event: String, properties: [String: Any] = [:]) {
|
||||||
|
#if DEBUG
|
||||||
|
if properties.isEmpty {
|
||||||
|
NSLog("Analytics: %@", event)
|
||||||
|
} else {
|
||||||
|
let formatted = properties
|
||||||
|
.map { "\($0.key)=\($0.value)" }
|
||||||
|
.sorted()
|
||||||
|
.joined(separator: ", ")
|
||||||
|
NSLog("Analytics: %@ { %@ }", event, formatted)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"fill-specializations" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"value" : {
|
||||||
"solid" : "display-p3:0.31812,0.56494,0.59766,1.00000"
|
"solid" : "display-p3:0.31765,0.56471,0.59608,1.00000"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,6 +84,29 @@
|
|||||||
"bom.navigation.title.system" = "BOM – %@";
|
"bom.navigation.title.system" = "BOM – %@";
|
||||||
"bom.size.unknown" = "Size TBD";
|
"bom.size.unknown" = "Size TBD";
|
||||||
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
"bom.terminals.detail" = "Ring or spade terminals sized for %@ wiring";
|
||||||
|
"bom.empty.message" = "No components saved in this system yet.";
|
||||||
|
"bom.export.pdf.button" = "Export PDF";
|
||||||
|
"bom.export.pdf.error.title" = "Export Failed";
|
||||||
|
"bom.export.pdf.error.empty" = "Add at least one component before exporting.";
|
||||||
|
"bom.pdf.header.title" = "System Bill of Materials";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Unit System: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "No components available.";
|
||||||
|
"bom.pdf.page.number" = "Page %d";
|
||||||
|
"bom.category.components.title" = "Components & Chargers";
|
||||||
|
"bom.category.components.subtitle" = "Primary devices, controllers, and charging gear.";
|
||||||
|
"bom.category.batteries.title" = "Batteries";
|
||||||
|
"bom.category.batteries.subtitle" = "House banks and storage.";
|
||||||
|
"bom.category.cables.title" = "Cables";
|
||||||
|
"bom.category.cables.subtitle" = "Sized power runs for every circuit.";
|
||||||
|
"bom.category.fuses.title" = "Fuses";
|
||||||
|
"bom.category.fuses.subtitle" = "Circuit protection and holders.";
|
||||||
|
"bom.category.accessories.title" = "Accessories";
|
||||||
|
"bom.category.accessories.subtitle" = "Fuses, lugs, and supporting hardware.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"cable.pro.privacy.label" = "Privacy";
|
"cable.pro.privacy.label" = "Privacy";
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
"cable.pro.privacy.url" = "https://voltplan.app/privacy";
|
||||||
"cable.pro.terms.label" = "Terms";
|
"cable.pro.terms.label" = "Terms";
|
||||||
@@ -277,6 +300,7 @@
|
|||||||
"cable.pro.alert.restored.body" = "Your purchases are available again.";
|
"cable.pro.alert.restored.body" = "Your purchases are available again.";
|
||||||
"cable.pro.alert.error.title" = "Purchase Failed";
|
"cable.pro.alert.error.title" = "Purchase Failed";
|
||||||
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
|
"cable.pro.alert.error.generic" = "Something went wrong. Please try again.";
|
||||||
|
"generic.ok" = "OK";
|
||||||
"cable.pro.trial.badge" = "Includes a %@ free trial";
|
"cable.pro.trial.badge" = "Includes a %@ free trial";
|
||||||
"cable.pro.subscription.renews" = "Renews %@.";
|
"cable.pro.subscription.renews" = "Renews %@.";
|
||||||
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
|
"cable.pro.subscription.trialThenRenews" = "Free trial, then renews %@.";
|
||||||
|
|||||||
@@ -154,26 +154,7 @@ struct BatteriesView: View {
|
|||||||
emptyState
|
emptyState
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
summarySection
|
batteriesListWithHeader
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach(batteries) { battery in
|
|
||||||
Button {
|
|
||||||
onEdit(battery)
|
|
||||||
} label: {
|
|
||||||
batteryRow(for: battery)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(editMode == .active)
|
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onDelete(perform: onDelete)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -197,7 +178,13 @@ struct BatteriesView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var batteryStatsHeader: some View {
|
||||||
|
StatsHeaderContainer {
|
||||||
|
batterySummaryContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var batterySummaryContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
@@ -225,15 +212,47 @@ struct BatteriesView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.background(Color(.separator))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var batteriesListWithHeader: some View {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
baseBatteriesList
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
batteryStatsHeader
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseBatteriesList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
batteryStatsHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseBatteriesList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(batteries) { battery in
|
||||||
|
Button {
|
||||||
|
onEdit(battery)
|
||||||
|
} label: {
|
||||||
|
batteryRow(for: battery)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: onDelete)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
}
|
||||||
|
|
||||||
private func batteryRow(for battery: SavedBattery) -> some View {
|
private func batteryRow(for battery: SavedBattery) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct BatteryEditorView: View {
|
|||||||
@State private var minimumTemperatureInput: String = ""
|
@State private var minimumTemperatureInput: String = ""
|
||||||
@State private var maximumTemperatureInput: String = ""
|
@State private var maximumTemperatureInput: String = ""
|
||||||
@State private var showingAppearanceEditor = false
|
@State private var showingAppearanceEditor = false
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
@State private var hasActiveProSubscription = false
|
@State private var hasActiveProSubscription = false
|
||||||
let onSave: (BatteryConfiguration) -> Void
|
let onSave: (BatteryConfiguration) -> Void
|
||||||
|
|
||||||
@@ -532,7 +533,10 @@ struct BatteryEditorView: View {
|
|||||||
CableProPaywallView(isPresented: $showingProUpsell)
|
CableProPaywallView(isPresented: $showingProUpsell)
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
|
}
|
||||||
|
.onReceive(storeKitManager.$status) { _ in
|
||||||
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
}
|
}
|
||||||
.alert(
|
.alert(
|
||||||
NSLocalizedString(
|
NSLocalizedString(
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import SwiftData
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct CableApp: App {
|
struct CableApp: App {
|
||||||
@StateObject private var unitSettings = UnitSystemSettings()
|
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
@StateObject private var unitSettings: UnitSystemSettings
|
||||||
|
@StateObject private var storeKitManager: StoreKitManager
|
||||||
|
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
do {
|
do {
|
||||||
@@ -31,8 +33,11 @@ struct CableApp: App {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
let unitSettings = UnitSystemSettings()
|
||||||
|
_unitSettings = StateObject(wrappedValue: unitSettings)
|
||||||
|
_storeKitManager = StateObject(wrappedValue: StoreKitManager(unitSettings: unitSettings))
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
UITestSampleData.prepareIfNeeded(container: sharedModelContainer)
|
UITestSampleData.handleLaunchArguments(container: sharedModelContainer)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ struct CableApp: App {
|
|||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(unitSettings)
|
.environmentObject(unitSettings)
|
||||||
|
.environmentObject(storeKitManager)
|
||||||
}
|
}
|
||||||
.modelContainer(sharedModelContainer)
|
.modelContainer(sharedModelContainer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,34 +110,20 @@ struct ChargersView: View {
|
|||||||
emptyState
|
emptyState
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
summarySection
|
chargersListWithHeader
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach(chargers) { charger in
|
|
||||||
Button {
|
|
||||||
onEdit(charger)
|
|
||||||
} label: {
|
|
||||||
chargerRow(for: charger)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(editMode == .active)
|
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onDelete(perform: onDelete)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
.accessibilityIdentifier("chargers-list")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var chargerStatsHeader: some View {
|
||||||
|
StatsHeaderContainer {
|
||||||
|
chargerSummaryContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chargerSummaryContent: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .firstTextBaseline) {
|
HStack(alignment: .firstTextBaseline) {
|
||||||
@@ -157,18 +143,50 @@ struct ChargersView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.trailing, 16)
|
.padding(.horizontal, 2)
|
||||||
}
|
}
|
||||||
.scrollClipDisabled(true)
|
.scrollClipDisabled(false)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
.background(Color(.separator))
|
|
||||||
.padding(.leading, 0)
|
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var chargersListWithHeader: some View {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
baseChargersList
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
chargerStatsHeader
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseChargersList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
chargerStatsHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseChargersList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(chargers) { charger in
|
||||||
|
Button {
|
||||||
|
onEdit(charger)
|
||||||
|
} label: {
|
||||||
|
chargerRow(for: charger)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: onDelete)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
.accessibilityIdentifier("chargers-list")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summaryMetrics: [SummaryMetric] {
|
private var summaryMetrics: [SummaryMetric] {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final class SavedCharger {
|
|||||||
var remoteIconURLString: String?
|
var remoteIconURLString: String?
|
||||||
var affiliateURLString: String?
|
var affiliateURLString: String?
|
||||||
var affiliateCountryCode: String?
|
var affiliateCountryCode: String?
|
||||||
|
var bomCompletedItemIDs: [String] = []
|
||||||
var identifier: String
|
var identifier: String
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -32,6 +33,7 @@ final class SavedCharger {
|
|||||||
remoteIconURLString: String? = nil,
|
remoteIconURLString: String? = nil,
|
||||||
affiliateURLString: String? = nil,
|
affiliateURLString: String? = nil,
|
||||||
affiliateCountryCode: String? = nil,
|
affiliateCountryCode: String? = nil,
|
||||||
|
bomCompletedItemIDs: [String] = [],
|
||||||
identifier: String = UUID().uuidString
|
identifier: String = UUID().uuidString
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -47,6 +49,7 @@ final class SavedCharger {
|
|||||||
self.remoteIconURLString = remoteIconURLString
|
self.remoteIconURLString = remoteIconURLString
|
||||||
self.affiliateURLString = affiliateURLString
|
self.affiliateURLString = affiliateURLString
|
||||||
self.affiliateCountryCode = affiliateCountryCode
|
self.affiliateCountryCode = affiliateCountryCode
|
||||||
|
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||||
self.identifier = identifier
|
self.identifier = identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,31 +34,12 @@ class CableCalculator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
|
func recommendedCrossSection(for unitSystem: UnitSystem) -> Double {
|
||||||
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048 // ft to m
|
ElectricalCalculations.recommendedCrossSection(
|
||||||
// Simplified calculation: minimum cross-section based on current and voltage drop
|
length: length,
|
||||||
let maxVoltageDrop = voltage * 0.05 // 5% voltage drop limit
|
current: current,
|
||||||
let resistivity = 0.017 // Copper resistivity at 20°C (Ω⋅mm²/m)
|
voltage: voltage,
|
||||||
let calculatedMinCrossSection = (2 * current * lengthInMeters * resistivity) / maxVoltageDrop
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
if unitSystem == .imperial {
|
|
||||||
// Standard AWG wire sizes
|
|
||||||
let standardAWG = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
|
|
||||||
let awgCrossSections = [0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0]
|
|
||||||
|
|
||||||
// Find the smallest AWG that meets the requirement
|
|
||||||
for (index, crossSection) in awgCrossSections.enumerated() {
|
|
||||||
if crossSection >= calculatedMinCrossSection {
|
|
||||||
return Double(standardAWG[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Double(standardAWG.last!) // Largest available
|
|
||||||
} else {
|
|
||||||
// Standard metric cable cross-sections in mm²
|
|
||||||
let standardSizes = [0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0, 150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0]
|
|
||||||
|
|
||||||
// Find the smallest standard size that meets the requirement
|
|
||||||
return standardSizes.first { $0 >= max(0.75, calculatedMinCrossSection) } ?? standardSizes.last!
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func crossSection(for unitSystem: UnitSystem) -> Double {
|
func crossSection(for unitSystem: UnitSystem) -> Double {
|
||||||
@@ -66,42 +47,34 @@ class CableCalculator: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
func voltageDrop(for unitSystem: UnitSystem) -> Double {
|
||||||
let lengthInMeters = unitSystem == .metric ? length : length * 0.3048
|
ElectricalCalculations.voltageDrop(
|
||||||
let crossSectionMM2 = unitSystem == .metric ? crossSection(for: unitSystem) : crossSectionFromAWG(crossSection(for: unitSystem))
|
length: length,
|
||||||
let resistivity = 0.017
|
current: current,
|
||||||
let effectiveCurrent = current // Always use the current property which gets updated
|
voltage: voltage,
|
||||||
return (2 * effectiveCurrent * lengthInMeters * resistivity) / crossSectionMM2
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
func voltageDropPercentage(for unitSystem: UnitSystem) -> Double {
|
||||||
(voltageDrop(for: unitSystem) / voltage) * 100
|
ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
func powerLoss(for unitSystem: UnitSystem) -> Double {
|
||||||
let effectiveCurrent = current
|
ElectricalCalculations.powerLoss(
|
||||||
return effectiveCurrent * voltageDrop(for: unitSystem)
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var recommendedFuse: Int {
|
var recommendedFuse: Int {
|
||||||
let targetFuse = current * 1.25 // 125% of load current for safety
|
ElectricalCalculations.recommendedFuse(forCurrent: current)
|
||||||
|
|
||||||
// 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!
|
|
||||||
}
|
|
||||||
|
|
||||||
// AWG conversion helper for voltage drop calculations
|
|
||||||
private func crossSectionFromAWG(_ awg: 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]
|
|
||||||
|
|
||||||
// Handle 00, 000, 0000 AWG (represented as negative values)
|
|
||||||
if awg == 00 { return 67.4 }
|
|
||||||
if awg == 000 { return 85.0 }
|
|
||||||
if awg == 0000 { return 107.0 }
|
|
||||||
|
|
||||||
return awgSizes[Int(awg)] ?? 0.75
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ struct CalculatorView: View {
|
|||||||
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
||||||
@State private var completedItemIDs: Set<String>
|
@State private var completedItemIDs: Set<String>
|
||||||
@State private var isAdvancedExpanded = false
|
@State private var isAdvancedExpanded = false
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
@State private var hasActiveProSubscription = false
|
@State private var hasActiveProSubscription = false
|
||||||
|
|
||||||
let savedLoad: SavedLoad?
|
let savedLoad: SavedLoad?
|
||||||
@@ -80,7 +81,10 @@ struct CalculatorView: View {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.task {
|
.task {
|
||||||
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
|
}
|
||||||
|
.onReceive(storeKitManager.$status) { _ in
|
||||||
|
hasActiveProSubscription = storeKitManager.isProUnlocked
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
138
Cable/Loads/ElectricalCalculations.swift
Normal file
138
Cable/Loads/ElectricalCalculations.swift
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// ElectricalCalculations.swift
|
||||||
|
// Cable
|
||||||
|
//
|
||||||
|
// Created by GPT on request.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ElectricalCalculations {
|
||||||
|
private static let maxVoltageDropFraction = 0.05
|
||||||
|
private static let copperResistivity = 0.017 // Ω⋅mm²/m
|
||||||
|
private static let feetToMeters = 0.3048
|
||||||
|
|
||||||
|
private static let standardMetricCrossSections: [Double] = [
|
||||||
|
0.75, 1.0, 1.5, 2.5, 4.0, 6.0, 10.0, 16.0, 25.0, 35.0, 50.0, 70.0, 95.0, 120.0,
|
||||||
|
150.0, 185.0, 240.0, 300.0, 400.0, 500.0, 630.0,
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let standardAWG: [Int] = [20, 18, 16, 14, 12, 10, 8, 6, 4, 2, 1, 0, 00, 000, 0000]
|
||||||
|
private static let awgCrossSections: [Double] = [
|
||||||
|
0.519, 0.823, 1.31, 2.08, 3.31, 5.26, 8.37, 13.3, 21.2, 33.6, 42.4, 53.5, 67.4, 85.0, 107.0,
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let standardFuses: [Int] = [
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
|
||||||
|
static func recommendedCrossSection(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem
|
||||||
|
) -> Double {
|
||||||
|
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
|
||||||
|
let maxVoltageDrop = voltage * maxVoltageDropFraction
|
||||||
|
let minimumCrossSection = guardAgainstZero(maxVoltageDrop) {
|
||||||
|
(2 * current * lengthInMeters * copperResistivity) / maxVoltageDrop
|
||||||
|
}
|
||||||
|
|
||||||
|
if unitSystem == .imperial {
|
||||||
|
for (index, crossSection) in awgCrossSections.enumerated() where crossSection >= minimumCrossSection {
|
||||||
|
return Double(standardAWG[index])
|
||||||
|
}
|
||||||
|
return Double(standardAWG.last ?? 0)
|
||||||
|
} else {
|
||||||
|
return standardMetricCrossSections.first { $0 >= max(standardMetricCrossSections.first ?? 0.75, minimumCrossSection) }
|
||||||
|
?? standardMetricCrossSections.last ?? 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func voltageDrop(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
crossSection: Double? = nil
|
||||||
|
) -> Double {
|
||||||
|
let selectedCrossSection = crossSection ?? recommendedCrossSection(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
let lengthInMeters = unitSystem == .metric ? length : length * feetToMeters
|
||||||
|
let crossSectionMM2: Double
|
||||||
|
if unitSystem == .metric {
|
||||||
|
crossSectionMM2 = selectedCrossSection
|
||||||
|
} else {
|
||||||
|
crossSectionMM2 = crossSectionFromAWG(selectedCrossSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard crossSectionMM2 > 0 else { return 0 }
|
||||||
|
return (2 * current * lengthInMeters * copperResistivity) / crossSectionMM2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func voltageDropPercentage(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
crossSection: Double? = nil
|
||||||
|
) -> Double {
|
||||||
|
guard voltage != 0 else { return 0 }
|
||||||
|
let drop = voltageDrop(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
crossSection: crossSection
|
||||||
|
)
|
||||||
|
return (drop / voltage) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
static func powerLoss(
|
||||||
|
length: Double,
|
||||||
|
current: Double,
|
||||||
|
voltage: Double,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
crossSection: Double? = nil
|
||||||
|
) -> Double {
|
||||||
|
let drop = voltageDrop(
|
||||||
|
length: length,
|
||||||
|
current: current,
|
||||||
|
voltage: voltage,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
crossSection: crossSection
|
||||||
|
)
|
||||||
|
return current * drop
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recommendedFuse(forCurrent current: Double) -> Int {
|
||||||
|
let target = Int((current * 1.25).rounded(.up))
|
||||||
|
return standardFuses.first(where: { $0 >= target }) ?? standardFuses.last ?? target
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func guardAgainstZero(_ divisor: Double, calculation: () -> Double) -> Double {
|
||||||
|
guard divisor > 0 else { return 0 }
|
||||||
|
return calculation()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func crossSectionFromAWG(_ awg: Double) -> Double {
|
||||||
|
switch awg {
|
||||||
|
case 00: return 67.4
|
||||||
|
case 000: return 85.0
|
||||||
|
case 0000: return 107.0
|
||||||
|
default:
|
||||||
|
let index = standardAWG.firstIndex(of: Int(awg)) ?? -1
|
||||||
|
if index >= 0 && index < awgCrossSections.count {
|
||||||
|
return awgCrossSections[index]
|
||||||
|
}
|
||||||
|
return 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "rectangle.3.group"
|
systemImage: "rectangle.3.group"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("overview-tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
componentsTab
|
componentsTab
|
||||||
@@ -76,6 +77,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "square.stack.3d.up"
|
systemImage: "square.stack.3d.up"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("components-tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
@@ -105,6 +107,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "battery.100"
|
systemImage: "battery.100"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("batteries-tab")
|
||||||
}
|
}
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
|
|
||||||
@@ -126,6 +129,7 @@ struct LoadsView: View {
|
|||||||
),
|
),
|
||||||
systemImage: "bolt.fill"
|
systemImage: "bolt.fill"
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("chargers-tab")
|
||||||
}
|
}
|
||||||
.environment(\.editMode, $editMode)
|
.environment(\.editMode, $editMode)
|
||||||
}
|
}
|
||||||
@@ -134,7 +138,7 @@ struct LoadsView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .principal) {
|
ToolbarItem(placement: .principal) {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showingSystemEditor = true
|
presentSystemEditor(source: "toolbar")
|
||||||
}) {
|
}) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -214,6 +218,8 @@ struct LoadsView: View {
|
|||||||
SystemBillOfMaterialsView(
|
SystemBillOfMaterialsView(
|
||||||
systemName: system.name,
|
systemName: system.name,
|
||||||
loads: savedLoads,
|
loads: savedLoads,
|
||||||
|
batteries: savedBatteries,
|
||||||
|
chargers: savedChargers,
|
||||||
unitSystem: unitSettings.unitSystem
|
unitSystem: unitSettings.unitSystem
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -258,13 +264,20 @@ struct LoadsView: View {
|
|||||||
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
if presentSystemEditorOnAppear && !hasPresentedSystemEditorOnAppear {
|
||||||
hasPresentedSystemEditorOnAppear = true
|
hasPresentedSystemEditorOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
showingSystemEditor = true
|
presentSystemEditor(source: "auto")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
if let loadToOpen = loadToOpenOnAppear, !hasOpenedLoadOnAppear {
|
||||||
hasOpenedLoadOnAppear = true
|
hasOpenedLoadOnAppear = true
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Load Opened",
|
||||||
|
properties: [
|
||||||
|
"mode": loadToOpen.isWattMode ? "watt" : "amp",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = loadToOpen
|
newLoadToEdit = loadToOpen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,109 +300,116 @@ struct LoadsView: View {
|
|||||||
onSelectBatteries: { selectedComponentTab = .batteries },
|
onSelectBatteries: { selectedComponentTab = .batteries },
|
||||||
onSelectChargers: { selectedComponentTab = .chargers },
|
onSelectChargers: { selectedComponentTab = .chargers },
|
||||||
onCreateLoad: { createNewLoad() },
|
onCreateLoad: { createNewLoad() },
|
||||||
onBrowseLibrary: { showingComponentLibrary = true },
|
onBrowseLibrary: { openComponentLibrary(source: "overview") },
|
||||||
onShowBillOfMaterials: { showingSystemBOM = true },
|
onShowBillOfMaterials: { openBillOfMaterials() },
|
||||||
onCreateBattery: { startBatteryConfiguration() },
|
onCreateBattery: { startBatteryConfiguration() },
|
||||||
onCreateCharger: { startChargerConfiguration() }
|
onCreateCharger: { startChargerConfiguration() }
|
||||||
)
|
)
|
||||||
.accessibilityIdentifier("system-overview")
|
.accessibilityIdentifier("system-overview")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var summarySection: some View {
|
private var loadsStatsHeader: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
StatsHeaderContainer {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
loadsSummaryContent
|
||||||
HStack(alignment: .firstTextBaseline) {
|
}
|
||||||
Text(loadsSummaryTitle)
|
}
|
||||||
.font(.headline.weight(.semibold))
|
|
||||||
Spacer()
|
private var loadsSummaryContent: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(loadsSummaryTitle)
|
||||||
|
.font(.headline.weight(.semibold))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewThatFits(in: .horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
summaryMetric(
|
||||||
|
icon: "square.stack.3d.up",
|
||||||
|
label: loadsCountLabel,
|
||||||
|
value: "\(savedLoads.count)",
|
||||||
|
tint: .blue
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "bolt.fill",
|
||||||
|
label: loadsCurrentLabel,
|
||||||
|
value: formattedCurrent(totalCurrent),
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
|
summaryMetric(
|
||||||
|
icon: "gauge.medium",
|
||||||
|
label: loadsPowerLabel,
|
||||||
|
value: formattedPower(totalPower),
|
||||||
|
tint: .green
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ViewThatFits(in: .horizontal) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack(spacing: 16) {
|
summaryMetric(
|
||||||
summaryMetric(
|
icon: "square.stack.3d.up",
|
||||||
icon: "square.stack.3d.up",
|
label: loadsCountLabel,
|
||||||
label: loadsCountLabel,
|
value: "\(savedLoads.count)",
|
||||||
value: "\(savedLoads.count)",
|
tint: .blue
|
||||||
tint: .blue
|
)
|
||||||
)
|
summaryMetric(
|
||||||
summaryMetric(
|
icon: "bolt.fill",
|
||||||
icon: "bolt.fill",
|
label: loadsCurrentLabel,
|
||||||
label: loadsCurrentLabel,
|
value: formattedCurrent(totalCurrent),
|
||||||
value: formattedCurrent(totalCurrent),
|
tint: .orange
|
||||||
tint: .orange
|
)
|
||||||
)
|
summaryMetric(
|
||||||
summaryMetric(
|
icon: "gauge.medium",
|
||||||
icon: "gauge.medium",
|
label: loadsPowerLabel,
|
||||||
label: loadsPowerLabel,
|
value: formattedPower(totalPower),
|
||||||
value: formattedPower(totalPower),
|
tint: .green
|
||||||
tint: .green
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
summaryMetric(
|
|
||||||
icon: "square.stack.3d.up",
|
|
||||||
label: loadsCountLabel,
|
|
||||||
value: "\(savedLoads.count)",
|
|
||||||
tint: .blue
|
|
||||||
)
|
|
||||||
summaryMetric(
|
|
||||||
icon: "bolt.fill",
|
|
||||||
label: loadsCurrentLabel,
|
|
||||||
value: formattedCurrent(totalCurrent),
|
|
||||||
tint: .orange
|
|
||||||
)
|
|
||||||
summaryMetric(
|
|
||||||
icon: "gauge.medium",
|
|
||||||
label: loadsPowerLabel,
|
|
||||||
value: formattedPower(totalPower),
|
|
||||||
tint: .green
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let status = loadStatus {
|
|
||||||
Button {
|
|
||||||
activeStatus = status
|
|
||||||
} label: {
|
|
||||||
statusBanner(for: status)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color(.systemGroupedBackground))
|
|
||||||
|
|
||||||
Divider()
|
if let status = loadStatus {
|
||||||
.background(Color(.separator))
|
Button {
|
||||||
|
activeStatus = status
|
||||||
libraryButton
|
} label: {
|
||||||
.padding(.trailing, 16)
|
statusBanner(for: status)
|
||||||
.padding(.bottom, 6)
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var libraryButton: some View {
|
private var libraryButton: some View {
|
||||||
Button {
|
Button {
|
||||||
showingComponentLibrary = true
|
openComponentLibrary(source: "library-button")
|
||||||
} label: {
|
} label: {
|
||||||
Label(
|
Group {
|
||||||
String(
|
if #available(iOS 26.0, *) {
|
||||||
localized: "loads.library.button",
|
libraryButtonLabel
|
||||||
bundle: .main,
|
.padding(.horizontal, 18)
|
||||||
comment: "Button title to open component library"
|
.padding(.vertical, 12)
|
||||||
),
|
.glassEffect(.regular, in: .capsule)
|
||||||
systemImage: "books.vertical"
|
} else {
|
||||||
)
|
libraryButtonLabel
|
||||||
.font(.footnote.weight(.semibold))
|
.padding(.horizontal, 14)
|
||||||
.padding(.horizontal, 14)
|
.padding(.vertical, 10)
|
||||||
.padding(.vertical, 10)
|
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
|
||||||
.background(.ultraThinMaterial, in: Capsule(style: .continuous))
|
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
.shadow(color: Color.black.opacity(0.08), radius: 16, x: 0, y: 8)
|
}
|
||||||
|
|
||||||
|
private var libraryButtonLabel: some View {
|
||||||
|
Label(
|
||||||
|
String(
|
||||||
|
localized: "loads.library.button",
|
||||||
|
bundle: .main,
|
||||||
|
comment: "Button title to open component library"
|
||||||
|
),
|
||||||
|
systemImage: "books.vertical"
|
||||||
|
)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var componentsTab: some View {
|
private var componentsTab: some View {
|
||||||
@@ -398,37 +418,69 @@ struct LoadsView: View {
|
|||||||
OnboardingInfoView(
|
OnboardingInfoView(
|
||||||
configuration: .loads(),
|
configuration: .loads(),
|
||||||
onPrimaryAction: { createNewLoad() },
|
onPrimaryAction: { createNewLoad() },
|
||||||
onSecondaryAction: { showingComponentLibrary = true }
|
onSecondaryAction: { openComponentLibrary(source: "components-onboarding") }
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 0)
|
.padding(.horizontal, 0)
|
||||||
} else {
|
} else {
|
||||||
summarySection
|
loadsListWithHeader
|
||||||
|
|
||||||
List {
|
|
||||||
ForEach(savedLoads) { load in
|
|
||||||
Button {
|
|
||||||
selectLoad(load)
|
|
||||||
} label: {
|
|
||||||
loadRow(for: load)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.disabled(editMode == .active)
|
|
||||||
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
|
||||||
.onDelete(perform: deleteLoads)
|
|
||||||
}
|
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.accessibilityIdentifier("loads-list")
|
|
||||||
.environment(\.editMode, $editMode)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var loadsListWithHeader: some View {
|
||||||
|
Group {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
baseLoadsList
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
loadsStatsHeader
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseLoadsList
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
loadsStatsHeader
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottomTrailing) {
|
||||||
|
libraryButton
|
||||||
|
.padding(.trailing, 24)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var baseLoadsList: some View {
|
||||||
|
List {
|
||||||
|
ForEach(savedLoads) { load in
|
||||||
|
Button {
|
||||||
|
selectLoad(load)
|
||||||
|
} label: {
|
||||||
|
loadRow(for: load)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(editMode == .active)
|
||||||
|
.listRowInsets(.init(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteLoads)
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.accessibilityIdentifier("loads-list")
|
||||||
|
.environment(\.editMode, $editMode)
|
||||||
|
}
|
||||||
|
|
||||||
private func selectLoad(_ load: SavedLoad) {
|
private func selectLoad(_ load: SavedLoad) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Load Opened",
|
||||||
|
properties: [
|
||||||
|
"mode": load.isWattMode ? "watt" : "amp",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = load
|
newLoadToEdit = load
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,14 +751,6 @@ struct LoadsView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteLoads(offsets: IndexSet) {
|
|
||||||
withAnimation {
|
|
||||||
for index in offsets {
|
|
||||||
modelContext.delete(savedLoads[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePrimaryAction() {
|
private func handlePrimaryAction() {
|
||||||
switch selectedComponentTab {
|
switch selectedComponentTab {
|
||||||
case .overview:
|
case .overview:
|
||||||
@@ -719,6 +763,54 @@ struct LoadsView: View {
|
|||||||
startChargerConfiguration()
|
startChargerConfiguration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentSystemEditor(source: String) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"System Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": source,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
showingSystemEditor = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openComponentLibrary(source: String) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Component Library Opened",
|
||||||
|
properties: [
|
||||||
|
"source": source,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
showingComponentLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openBillOfMaterials() {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Bill Of Materials Opened",
|
||||||
|
properties: [
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
showingSystemBOM = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteLoads(offsets: IndexSet) {
|
||||||
|
let loadsToDelete = offsets.map { savedLoads[$0] }
|
||||||
|
withAnimation {
|
||||||
|
for load in loadsToDelete {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Load Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": load.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
modelContext.delete(load)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func createNewLoad() {
|
private func createNewLoad() {
|
||||||
let newLoad = SystemComponentsPersistence.createDefaultLoad(
|
let newLoad = SystemComponentsPersistence.createDefaultLoad(
|
||||||
@@ -728,10 +820,24 @@ struct LoadsView: View {
|
|||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
existingChargers: savedChargers
|
existingChargers: savedChargers
|
||||||
)
|
)
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Load Created",
|
||||||
|
properties: [
|
||||||
|
"name": newLoad.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startBatteryConfiguration() {
|
private func startBatteryConfiguration() {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Battery Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "create",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
batteryDraft = SystemComponentsPersistence.makeBatteryDraft(
|
||||||
for: system,
|
for: system,
|
||||||
existingLoads: savedLoads,
|
existingLoads: savedLoads,
|
||||||
@@ -741,20 +847,46 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveBattery(_ configuration: BatteryConfiguration) {
|
private func saveBattery(_ configuration: BatteryConfiguration) {
|
||||||
|
let isExisting = savedBatteries.contains { $0.id == configuration.id }
|
||||||
SystemComponentsPersistence.saveBattery(
|
SystemComponentsPersistence.saveBattery(
|
||||||
configuration,
|
configuration,
|
||||||
for: system,
|
for: system,
|
||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
in: modelContext
|
in: modelContext
|
||||||
)
|
)
|
||||||
|
let eventName = isExisting ? "Battery Updated" : "Battery Created"
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
eventName,
|
||||||
|
properties: [
|
||||||
|
"name": configuration.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func editBattery(_ battery: SavedBattery) {
|
private func editBattery(_ battery: SavedBattery) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Battery Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "edit",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
|
batteryDraft = BatteryConfiguration(savedBattery: battery, system: system)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteBatteries(_ offsets: IndexSet) {
|
private func deleteBatteries(_ offsets: IndexSet) {
|
||||||
|
let batteriesToDelete = offsets.map { savedBatteries[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
for battery in batteriesToDelete {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Battery Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": battery.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
SystemComponentsPersistence.deleteBatteries(
|
SystemComponentsPersistence.deleteBatteries(
|
||||||
at: offsets,
|
at: offsets,
|
||||||
from: savedBatteries,
|
from: savedBatteries,
|
||||||
@@ -764,6 +896,13 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startChargerConfiguration() {
|
private func startChargerConfiguration() {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Charger Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "create",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
|
chargerDraft = SystemComponentsPersistence.makeChargerDraft(
|
||||||
for: system,
|
for: system,
|
||||||
existingLoads: savedLoads,
|
existingLoads: savedLoads,
|
||||||
@@ -773,20 +912,46 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveCharger(_ configuration: ChargerConfiguration) {
|
private func saveCharger(_ configuration: ChargerConfiguration) {
|
||||||
|
let isExisting = savedChargers.contains { $0.id == configuration.id }
|
||||||
SystemComponentsPersistence.saveCharger(
|
SystemComponentsPersistence.saveCharger(
|
||||||
configuration,
|
configuration,
|
||||||
for: system,
|
for: system,
|
||||||
existingChargers: savedChargers,
|
existingChargers: savedChargers,
|
||||||
in: modelContext
|
in: modelContext
|
||||||
)
|
)
|
||||||
|
let eventName = isExisting ? "Charger Updated" : "Charger Created"
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
eventName,
|
||||||
|
properties: [
|
||||||
|
"name": configuration.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func editCharger(_ charger: SavedCharger) {
|
private func editCharger(_ charger: SavedCharger) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Charger Editor Opened",
|
||||||
|
properties: [
|
||||||
|
"source": "edit",
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
|
chargerDraft = ChargerConfiguration(savedCharger: charger, system: system)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteChargers(_ offsets: IndexSet) {
|
private func deleteChargers(_ offsets: IndexSet) {
|
||||||
|
let chargersToDelete = offsets.map { savedChargers[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
for charger in chargersToDelete {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Charger Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": charger.name,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
SystemComponentsPersistence.deleteChargers(
|
SystemComponentsPersistence.deleteChargers(
|
||||||
at: offsets,
|
at: offsets,
|
||||||
from: savedChargers,
|
from: savedChargers,
|
||||||
@@ -804,6 +969,14 @@ struct LoadsView: View {
|
|||||||
existingBatteries: savedBatteries,
|
existingBatteries: savedBatteries,
|
||||||
existingChargers: savedChargers
|
existingChargers: savedChargers
|
||||||
)
|
)
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Library Load Added",
|
||||||
|
properties: [
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.localizedName,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
newLoadToEdit = newLoad
|
newLoadToEdit = newLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,13 +991,7 @@ struct LoadsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func recommendedFuse(for load: SavedLoad) -> Int {
|
private func recommendedFuse(for load: SavedLoad) -> Int {
|
||||||
let targetFuse = load.current * 1.25 // 125% of load current for safety
|
ElectricalCalculations.recommendedFuse(forCurrent: load.current)
|
||||||
|
|
||||||
// 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!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ComponentTab: Hashable {
|
private enum ComponentTab: Hashable {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ struct OnboardingInfoView: View {
|
|||||||
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
|
Label(configuration.primaryActionTitle, systemImage: configuration.primaryActionIcon)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("create-component-button")
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
|
||||||
@@ -71,6 +72,7 @@ struct OnboardingInfoView: View {
|
|||||||
Label(secondaryTitle, systemImage: secondaryIcon)
|
Label(secondaryTitle, systemImage: secondaryIcon)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("select-component-button")
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
|||||||
@@ -25,20 +25,55 @@ struct SystemOverviewView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
systemCard
|
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.top, 20)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
|
|
||||||
ScrollView {
|
if #available(iOS 26.0, *) {
|
||||||
VStack(spacing: 16) {
|
ScrollView {
|
||||||
loadsCard
|
VStack(spacing: 16) {
|
||||||
batteriesCard
|
loadsCard
|
||||||
chargersCard
|
batteriesCard
|
||||||
}
|
chargersCard
|
||||||
.padding(.horizontal, 16)
|
}
|
||||||
.padding(.bottom, 20)
|
.padding(.horizontal, 16)
|
||||||
}
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
// let the content slide under the top inset
|
||||||
|
// choose the Liquid Glass-friendly edge style
|
||||||
|
.scrollEdgeEffectStyle(.soft, for: .top)
|
||||||
|
|
||||||
|
// pin the Liquid Glass system card at the top safe area
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
systemCard
|
||||||
|
// Liquid Glass on custom view
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
loadsCard
|
||||||
|
batteriesCard
|
||||||
|
chargersCard
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 20)
|
||||||
|
}
|
||||||
|
// Don’t ignore the safe area — we want content to sit below the header
|
||||||
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
|
systemCard
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.background(Color(.systemGroupedBackground)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
@@ -80,8 +115,6 @@ struct SystemOverviewView: View {
|
|||||||
|
|
||||||
private var systemCard: some View {
|
private var systemCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
Text(systemOverviewTitle)
|
|
||||||
.font(.headline.weight(.semibold))
|
|
||||||
|
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
overviewMetricRow(
|
overviewMetricRow(
|
||||||
@@ -99,21 +132,6 @@ struct SystemOverviewView: View {
|
|||||||
action: openRuntimeGoalEditor
|
action: openRuntimeGoalEditor
|
||||||
)
|
)
|
||||||
|
|
||||||
overviewMetricRow(
|
|
||||||
title: bomTitle,
|
|
||||||
subtitle: bomSubtitle,
|
|
||||||
icon: "list.bullet.rectangle",
|
|
||||||
tint: .purple,
|
|
||||||
value: formattedBOMCompletedCount,
|
|
||||||
placeholder: bomPlaceholderSummary,
|
|
||||||
goal: formattedBOMTotalCount,
|
|
||||||
actualHours: nil,
|
|
||||||
goalHours: nil,
|
|
||||||
progressFraction: bomCompletionFraction,
|
|
||||||
hasValue: bomItemsCount > 0,
|
|
||||||
action: onShowBillOfMaterials
|
|
||||||
)
|
|
||||||
|
|
||||||
overviewMetricRow(
|
overviewMetricRow(
|
||||||
title: chargeTimeTitle,
|
title: chargeTimeTitle,
|
||||||
subtitle: chargeTimeSubtitle,
|
subtitle: chargeTimeSubtitle,
|
||||||
@@ -128,6 +146,23 @@ struct SystemOverviewView: View {
|
|||||||
hasValue: formattedChargeTime != nil,
|
hasValue: formattedChargeTime != nil,
|
||||||
action: openChargeGoalEditor
|
action: openChargeGoalEditor
|
||||||
)
|
)
|
||||||
|
|
||||||
|
overviewMetricRow(
|
||||||
|
title: bomTitle,
|
||||||
|
subtitle: bomSubtitle,
|
||||||
|
icon: "list.bullet.rectangle",
|
||||||
|
tint: .purple,
|
||||||
|
value: formattedBOMCompletedCount,
|
||||||
|
placeholder: bomPlaceholderSummary,
|
||||||
|
goal: formattedBOMTotalCount,
|
||||||
|
actualHours: nil,
|
||||||
|
goalHours: nil,
|
||||||
|
progressFraction: bomCompletionFraction,
|
||||||
|
hasValue: bomItemsCount > 0,
|
||||||
|
action: onShowBillOfMaterials,
|
||||||
|
accessibilityIdentifier: "system-bom-button"
|
||||||
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
@@ -139,7 +174,7 @@ struct SystemOverviewView: View {
|
|||||||
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
|
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
.fill(Color(.systemBackground))
|
.fill(Color(red: 81/255, green: 144/255, blue: 152/255).opacity(0.12))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -157,7 +192,8 @@ struct SystemOverviewView: View {
|
|||||||
goalHours: Double?,
|
goalHours: Double?,
|
||||||
progressFraction: Double?,
|
progressFraction: Double?,
|
||||||
hasValue: Bool,
|
hasValue: Bool,
|
||||||
action: (() -> Void)? = nil
|
action: (() -> Void)? = nil,
|
||||||
|
accessibilityIdentifier: String? = nil
|
||||||
) -> some View {
|
) -> some View {
|
||||||
let content = VStack(alignment: .leading, spacing: 10) {
|
let content = VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack(alignment: .center, spacing: 12) {
|
HStack(alignment: .center, spacing: 12) {
|
||||||
@@ -207,12 +243,28 @@ struct SystemOverviewView: View {
|
|||||||
|
|
||||||
let paddedContent = content
|
let paddedContent = content
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
|
||||||
if let action {
|
if let action {
|
||||||
Button(action: action) {
|
if let accessibilityIdentifier {
|
||||||
paddedContent
|
Button(action: action) {
|
||||||
|
paddedContent
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier(accessibilityIdentifier)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
} else {
|
||||||
|
Button(action: action) {
|
||||||
|
paddedContent
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(title)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
} else {
|
} else {
|
||||||
paddedContent
|
paddedContent
|
||||||
}
|
}
|
||||||
@@ -559,6 +611,16 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var totalAverageLoadPower: Double {
|
||||||
|
loads.reduce(0) { result, load in
|
||||||
|
let power = max(load.power, 0)
|
||||||
|
guard power > 0 else { return result }
|
||||||
|
let dutyCycleFraction = max(min(load.dutyCyclePercent, 100), 0) / 100
|
||||||
|
let usageFraction = max(min(load.dailyUsageHours, 24), 0) / 24
|
||||||
|
return result + power * dutyCycleFraction * usageFraction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var totalCapacity: Double {
|
private var totalCapacity: Double {
|
||||||
batteries.reduce(0) { result, battery in
|
batteries.reduce(0) { result, battery in
|
||||||
result + battery.capacityAmpHours
|
result + battery.capacityAmpHours
|
||||||
@@ -681,9 +743,11 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var completedBOMItemCount: Int {
|
private var completedBOMItemCount: Int {
|
||||||
settledLoads.reduce(into: Set<String>()) { partialResult, load in
|
settledLoads.reduce(0) { result, load in
|
||||||
load.bomCompletedItemIDs.forEach { partialResult.insert($0) }
|
let uniqueItems = Set(load.bomCompletedItemIDs)
|
||||||
}.count
|
let cappedCount = min(uniqueItems.count, Self.bomItemsPerLoad)
|
||||||
|
return result + cappedCount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var bomItemsCount: Int {
|
private var bomItemsCount: Int {
|
||||||
@@ -761,8 +825,8 @@ struct SystemOverviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var estimatedRuntimeHours: Double? {
|
private var estimatedRuntimeHours: Double? {
|
||||||
guard totalPower > 0, totalUsableEnergy > 0 else { return nil }
|
guard totalAverageLoadPower > 0, totalUsableEnergy > 0 else { return nil }
|
||||||
let hours = totalUsableEnergy / totalPower
|
let hours = totalUsableEnergy / totalAverageLoadPower
|
||||||
return hours.isFinite && hours > 0 ? hours : nil
|
return hours.isFinite && hours > 0 ? hours : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1406,7 +1470,9 @@ private struct GoalEditorSheet: View {
|
|||||||
name: "12V DC System",
|
name: "12V DC System",
|
||||||
location: "Engine Room",
|
location: "Engine Room",
|
||||||
iconName: "bolt.circle.fill",
|
iconName: "bolt.circle.fill",
|
||||||
colorName: "blue"
|
colorName: "blue",
|
||||||
|
targetRuntimeHours: 24,
|
||||||
|
targetChargeTimeHours: 3
|
||||||
)
|
)
|
||||||
//system.targetRuntimeHours = 8
|
//system.targetRuntimeHours = 8
|
||||||
//system.targetChargeTimeHours = 6
|
//system.targetChargeTimeHours = 6
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ final class CableProPaywallViewModel: ObservableObject {
|
|||||||
for await result in Transaction.currentEntitlements {
|
for await result in Transaction.currentEntitlements {
|
||||||
switch result {
|
switch result {
|
||||||
case .verified(let transaction):
|
case .verified(let transaction):
|
||||||
unlocked.insert(transaction.productID)
|
if productIdentifiers.contains(transaction.productID) {
|
||||||
|
unlocked.insert(transaction.productID)
|
||||||
|
}
|
||||||
case .unverified:
|
case .unverified:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -132,14 +134,12 @@ struct CableProPaywallView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@EnvironmentObject private var unitSettings: UnitSystemSettings
|
@EnvironmentObject private var unitSettings: UnitSystemSettings
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
|
|
||||||
@StateObject private var viewModel: CableProPaywallViewModel
|
@StateObject private var viewModel: CableProPaywallViewModel
|
||||||
@State private var alertInfo: PaywallAlert?
|
@State private var alertInfo: PaywallAlert?
|
||||||
|
|
||||||
private static let defaultProductIds = [
|
private static let defaultProductIds = StoreKitManager.subscriptionProductIDs
|
||||||
"app.voltplan.cable.weekly",
|
|
||||||
"app.voltplan.cable.yearly"
|
|
||||||
]
|
|
||||||
|
|
||||||
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
|
init(isPresented: Binding<Bool>, productIdentifiers: [String] = CableProPaywallView.defaultProductIds) {
|
||||||
_isPresented = isPresented
|
_isPresented = isPresented
|
||||||
@@ -168,9 +168,12 @@ struct CableProPaywallView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.loadProducts(force: true)
|
await viewModel.loadProducts(force: true)
|
||||||
unitSettings.isProUnlocked = !viewModel.purchasedProductIDs.isEmpty
|
await storeKitManager.refreshEntitlements()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.loadProducts(force: true)
|
||||||
|
await storeKitManager.refreshEntitlements()
|
||||||
}
|
}
|
||||||
.refreshable { await viewModel.loadProducts(force: true) }
|
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.alert) { newValue in
|
.onChange(of: viewModel.alert) { newValue in
|
||||||
alertInfo = newValue
|
alertInfo = newValue
|
||||||
@@ -186,7 +189,7 @@ struct CableProPaywallView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.purchasedProductIDs) { newValue in
|
.onChange(of: viewModel.purchasedProductIDs) { newValue in
|
||||||
unitSettings.isProUnlocked = !newValue.isEmpty
|
Task { await storeKitManager.refreshEntitlements() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,5 +538,9 @@ struct PaywallAlert: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
CableProPaywallView(isPresented: .constant(true))
|
let unitSettings = UnitSystemSettings()
|
||||||
|
let manager = StoreKitManager(unitSettings: unitSettings)
|
||||||
|
return CableProPaywallView(isPresented: .constant(true))
|
||||||
|
.environmentObject(unitSettings)
|
||||||
|
.environmentObject(manager)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class SavedBattery {
|
|||||||
var iconName: String = "battery.100"
|
var iconName: String = "battery.100"
|
||||||
var colorName: String = "blue"
|
var colorName: String = "blue"
|
||||||
var system: ElectricalSystem?
|
var system: ElectricalSystem?
|
||||||
|
var bomCompletedItemIDs: [String] = []
|
||||||
var timestamp: Date
|
var timestamp: Date
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -32,6 +33,7 @@ class SavedBattery {
|
|||||||
iconName: String = "battery.100",
|
iconName: String = "battery.100",
|
||||||
colorName: String = "blue",
|
colorName: String = "blue",
|
||||||
system: ElectricalSystem? = nil,
|
system: ElectricalSystem? = nil,
|
||||||
|
bomCompletedItemIDs: [String] = [],
|
||||||
timestamp: Date = Date()
|
timestamp: Date = Date()
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
@@ -47,6 +49,7 @@ class SavedBattery {
|
|||||||
self.iconName = iconName
|
self.iconName = iconName
|
||||||
self.colorName = colorName
|
self.colorName = colorName
|
||||||
self.system = system
|
self.system = system
|
||||||
|
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||||
self.timestamp = timestamp
|
self.timestamp = timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,17 +7,14 @@
|
|||||||
|
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
|
||||||
import StoreKit
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var unitSettings: UnitSystemSettings
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
||||||
|
@EnvironmentObject private var storeKitManager: StoreKitManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
@State private var showingProPaywall = false
|
@State private var showingProPaywall = false
|
||||||
@State private var isLoadingProStatus = true
|
|
||||||
@State private var proStatus: ProSubscriptionStatus?
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -75,29 +72,28 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task { await loadProStatus() }
|
|
||||||
.sheet(isPresented: $showingProPaywall) {
|
.sheet(isPresented: $showingProPaywall) {
|
||||||
CableProPaywallView(isPresented: $showingProPaywall)
|
CableProPaywallView(isPresented: $showingProPaywall)
|
||||||
}
|
}
|
||||||
.onChange(of: showingProPaywall) { isPresented in
|
.onChange(of: showingProPaywall) { isPresented in
|
||||||
if !isPresented {
|
if !isPresented {
|
||||||
Task { await loadProStatus() }
|
Task { await storeKitManager.refreshEntitlements() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
Task { await loadProStatus() }
|
Task { await storeKitManager.refreshEntitlements() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var proSectionContent: some View {
|
private var proSectionContent: some View {
|
||||||
if isLoadingProStatus {
|
if storeKitManager.isRefreshing && storeKitManager.status == nil {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
ProgressView()
|
ProgressView()
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else if let status = proStatus {
|
} else if let status = storeKitManager.status {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Label(status.displayName, systemImage: "checkmark.seal.fill")
|
Label(status.displayName, systemImage: "checkmark.seal.fill")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
@@ -113,6 +109,18 @@ struct SettingsView: View {
|
|||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if status.isInGracePeriod {
|
||||||
|
Text(localizedString("settings.pro.grace_period", defaultValue: "We're retrying your last payment; access remains during the grace period."))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let isAutoRenewEnabled = status.isAutoRenewEnabled, !isAutoRenewEnabled {
|
||||||
|
Text(localizedString("settings.pro.autorenew.off", defaultValue: "Auto-renew is off—consider renewing to keep Cable PRO."))
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
|
Text(localizedString("settings.pro.instructions", defaultValue: "Manage or cancel your subscription in the App Store."))
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
@@ -149,15 +157,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadProStatus() async {
|
|
||||||
isLoadingProStatus = true
|
|
||||||
defer { isLoadingProStatus = false }
|
|
||||||
let status = await SettingsView.fetchProStatus()
|
|
||||||
proStatus = status
|
|
||||||
unitSettings.isProUnlocked = status != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func renewalText(for date: Date) -> String {
|
private func renewalText(for date: Date) -> String {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .medium
|
formatter.dateStyle = .medium
|
||||||
@@ -168,7 +167,7 @@ struct SettingsView: View {
|
|||||||
return String(format: template, dateString)
|
return String(format: template, dateString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func trialMessage(for status: ProSubscriptionStatus) -> String? {
|
private func trialMessage(for status: StoreKitManager.SubscriptionStatus) -> String? {
|
||||||
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
|
guard status.isInTrial, let endDate = status.trialEndDate else { return nil }
|
||||||
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
|
let days = max(Calendar.autoupdatingCurrent.dateComponents([.day], from: Date(), to: endDate).day ?? 0, 0)
|
||||||
if days > 0 {
|
if days > 0 {
|
||||||
@@ -199,48 +198,15 @@ struct SettingsView: View {
|
|||||||
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
return formatter.string(from: NSNumber(value: value)) ?? String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func fetchProStatus() async -> ProSubscriptionStatus? {
|
|
||||||
let productIDs = Set(["app.voltplan.cable.weekly", "app.voltplan.cable.yearly"])
|
|
||||||
for await result in Transaction.currentEntitlements {
|
|
||||||
guard case .verified(let transaction) = result,
|
|
||||||
productIDs.contains(transaction.productID) else { continue }
|
|
||||||
|
|
||||||
let product = try? await Product.products(for: [transaction.productID]).first
|
|
||||||
let displayName = product?.displayName ?? transaction.productID
|
|
||||||
let renewalDate = transaction.expirationDate
|
|
||||||
|
|
||||||
let hasIntroOffer = transaction.offerType == .introductory
|
|
||||||
let paymentMode = product?.subscription?.introductoryOffer?.paymentMode
|
|
||||||
let isInTrial = hasIntroOffer && paymentMode == .freeTrial
|
|
||||||
let trialEndDate = isInTrial ? transaction.expirationDate : nil
|
|
||||||
|
|
||||||
return ProSubscriptionStatus(
|
|
||||||
productId: transaction.productID,
|
|
||||||
displayName: displayName,
|
|
||||||
renewalDate: renewalDate,
|
|
||||||
isInTrial: isInTrial,
|
|
||||||
trialEndDate: trialEndDate
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func localizedString(_ key: String, defaultValue: String) -> String {
|
private func localizedString(_ key: String, defaultValue: String) -> String {
|
||||||
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
NSLocalizedString(key, tableName: nil, bundle: .main, value: defaultValue, comment: "")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProSubscriptionStatus {
|
|
||||||
let productId: String
|
|
||||||
let displayName: String
|
|
||||||
let renewalDate: Date?
|
|
||||||
let isInTrial: Bool
|
|
||||||
let trialEndDate: Date?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview("Settings (Default)") {
|
#Preview("Settings (Default)") {
|
||||||
let settings = UnitSystemSettings()
|
let settings = UnitSystemSettings()
|
||||||
|
let manager = StoreKitManager(unitSettings: settings)
|
||||||
return SettingsView()
|
return SettingsView()
|
||||||
.environmentObject(settings)
|
.environmentObject(settings)
|
||||||
|
.environmentObject(manager)
|
||||||
}
|
}
|
||||||
|
|||||||
11
Cable/Shared/ShareSheet.swift
Normal file
11
Cable/Shared/ShareSheet.swift
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ShareSheet: UIViewControllerRepresentable {
|
||||||
|
let items: [Any]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
|
}
|
||||||
48
Cable/StatsHeaderContainer.swift
Normal file
48
Cable/StatsHeaderContainer.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Reusable wrapper that applies the system overview stats card styling to a header view.
|
||||||
|
struct StatsHeaderContainer<Content: View>: View {
|
||||||
|
private let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
card
|
||||||
|
.glassEffect(.regular, in: .rect(cornerRadius: 20))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
} else {
|
||||||
|
card
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var card: some View {
|
||||||
|
content
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(Color(red: 81 / 255, green: 144 / 255, blue: 152 / 255).opacity(0.12))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.stroke(Color(.separator).opacity(0.18), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
221
Cable/StoreKitManager.swift
Normal file
221
Cable/StoreKitManager.swift
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import Foundation
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class StoreKitManager: ObservableObject {
|
||||||
|
struct SubscriptionStatus: Equatable {
|
||||||
|
let productId: String
|
||||||
|
let displayName: String
|
||||||
|
let renewalDate: Date?
|
||||||
|
let isInTrial: Bool
|
||||||
|
let trialEndDate: Date?
|
||||||
|
let isInGracePeriod: Bool
|
||||||
|
let isAutoRenewEnabled: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static let subscriptionProductIDs: [String] = [
|
||||||
|
"app.voltplan.cable.weekly",
|
||||||
|
"app.voltplan.cable.yearly"
|
||||||
|
]
|
||||||
|
|
||||||
|
@Published private(set) var status: SubscriptionStatus?
|
||||||
|
@Published private(set) var isRefreshing = false
|
||||||
|
|
||||||
|
var isProUnlocked: Bool {
|
||||||
|
status != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private let productIDs: Set<String>
|
||||||
|
private weak var unitSettings: UnitSystemSettings?
|
||||||
|
private var updatesTask: Task<Void, Never>?
|
||||||
|
private var productCache: [String: Product] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
productIDs: [String] = StoreKitManager.subscriptionProductIDs,
|
||||||
|
unitSettings: UnitSystemSettings? = nil
|
||||||
|
) {
|
||||||
|
self.productIDs = Set(productIDs)
|
||||||
|
self.unitSettings = unitSettings
|
||||||
|
|
||||||
|
updatesTask = Task { [weak self] in
|
||||||
|
await self?.observeTransactionUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.finishUnfinishedTransactions()
|
||||||
|
await self?.refreshEntitlements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachUnitSettings(_ settings: UnitSystemSettings) {
|
||||||
|
unitSettings = settings
|
||||||
|
Task { [weak self] in
|
||||||
|
await self?.refreshEntitlements()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshEntitlements() async {
|
||||||
|
guard !isRefreshing else { return }
|
||||||
|
|
||||||
|
isRefreshing = true
|
||||||
|
defer { isRefreshing = false }
|
||||||
|
|
||||||
|
let resolvedStatus = await loadCurrentStatus()
|
||||||
|
status = resolvedStatus
|
||||||
|
unitSettings?.isProUnlocked = resolvedStatus != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadCurrentStatus() async -> SubscriptionStatus? {
|
||||||
|
if let entitlementStatus = await statusFromCurrentEntitlements() {
|
||||||
|
return entitlementStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
return await statusFromLatestTransactions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusFromCurrentEntitlements() async -> SubscriptionStatus? {
|
||||||
|
var newestTransaction: StoreKit.Transaction?
|
||||||
|
|
||||||
|
for await result in StoreKit.Transaction.currentEntitlements {
|
||||||
|
guard case .verified(let transaction) = result,
|
||||||
|
productIDs.contains(transaction.productID),
|
||||||
|
transaction.revocationDate == nil,
|
||||||
|
!isExpired(transaction) else { continue }
|
||||||
|
|
||||||
|
if let existing = newestTransaction {
|
||||||
|
let existingExpiration = existing.expirationDate ?? .distantPast
|
||||||
|
let candidateExpiration = transaction.expirationDate ?? .distantPast
|
||||||
|
if candidateExpiration > existingExpiration {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeTransaction = newestTransaction else { return nil }
|
||||||
|
return await status(for: activeTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusFromLatestTransactions() async -> SubscriptionStatus? {
|
||||||
|
var newestTransaction: StoreKit.Transaction?
|
||||||
|
|
||||||
|
for productID in productIDs {
|
||||||
|
guard let latestResult = await StoreKit.Transaction.latest(for: productID) else { continue }
|
||||||
|
guard case .verified(let transaction) = latestResult,
|
||||||
|
transaction.revocationDate == nil,
|
||||||
|
!isExpired(transaction) else { continue }
|
||||||
|
|
||||||
|
if let existing = newestTransaction {
|
||||||
|
let existingExpiration = existing.expirationDate ?? .distantPast
|
||||||
|
let candidateExpiration = transaction.expirationDate ?? .distantPast
|
||||||
|
if candidateExpiration > existingExpiration {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newestTransaction = transaction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let activeTransaction = newestTransaction else { return nil }
|
||||||
|
return await status(for: activeTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeTransactionUpdates() async {
|
||||||
|
for await result in StoreKit.Transaction.updates {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .verified(let transaction):
|
||||||
|
await transaction.finish()
|
||||||
|
await refreshEntitlements()
|
||||||
|
case .unverified:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishUnfinishedTransactions() async {
|
||||||
|
for await result in StoreKit.Transaction.unfinished {
|
||||||
|
guard case .verified(let transaction) = result else { continue }
|
||||||
|
await transaction.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func status(for transaction: StoreKit.Transaction) async -> SubscriptionStatus? {
|
||||||
|
let product = await product(for: transaction.productID)
|
||||||
|
let displayName = product?.displayName ?? transaction.productID
|
||||||
|
|
||||||
|
var isInGracePeriod = false
|
||||||
|
var isAutoRenewEnabled: Bool?
|
||||||
|
var isInTrial = false
|
||||||
|
var trialEndDate: Date?
|
||||||
|
|
||||||
|
if let currentStatus = await transaction.subscriptionStatus {
|
||||||
|
if currentStatus.state == .inGracePeriod {
|
||||||
|
isInGracePeriod = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .verified(let renewalInfo) = currentStatus.renewalInfo {
|
||||||
|
isAutoRenewEnabled = renewalInfo.willAutoRenew
|
||||||
|
|
||||||
|
if renewalInfo.gracePeriodExpirationDate != nil {
|
||||||
|
isInGracePeriod = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) {
|
||||||
|
if let offer = renewalInfo.offer, offer.type == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = transaction.expirationDate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#if compiler(>=5.3)
|
||||||
|
if renewalInfo.offerType == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = transaction.expirationDate
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else if case .verified(let statusTransaction) = currentStatus.transaction {
|
||||||
|
if let offer = statusTransaction.offer, offer.type == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = statusTransaction.expirationDate ?? transaction.expirationDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let offer = transaction.offer, offer.type == .introductory {
|
||||||
|
isInTrial = true
|
||||||
|
trialEndDate = transaction.expirationDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return SubscriptionStatus(
|
||||||
|
productId: transaction.productID,
|
||||||
|
displayName: displayName,
|
||||||
|
renewalDate: transaction.expirationDate,
|
||||||
|
isInTrial: isInTrial,
|
||||||
|
trialEndDate: trialEndDate,
|
||||||
|
isInGracePeriod: isInGracePeriod,
|
||||||
|
isAutoRenewEnabled: isAutoRenewEnabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isExpired(_ transaction: StoreKit.Transaction) -> Bool {
|
||||||
|
if let expirationDate = transaction.expirationDate {
|
||||||
|
return expirationDate < Date()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func product(for id: String) async -> Product? {
|
||||||
|
if let cached = productCache[id] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let product = try? await Product.products(for: [id]).first else { return nil }
|
||||||
|
productCache[id] = product
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
}
|
||||||
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal file
17
Cable/Systems/BillOfMaterialsSnapshot.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BillOfMaterialsItemSnapshot: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let detail: String
|
||||||
|
let iconSystemName: String
|
||||||
|
let isPrimaryComponent: Bool
|
||||||
|
let metric: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BillOfMaterialsSectionSnapshot: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let title: String
|
||||||
|
let subtitle: String
|
||||||
|
let items: [BillOfMaterialsItemSnapshot]
|
||||||
|
}
|
||||||
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal file
313
Cable/Systems/SystemBillOfMaterialsPDFExporter.swift
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct SystemBillOfMaterialsPDFExporter {
|
||||||
|
private let pageRect = CGRect(x: 0, y: 0, width: 595, height: 842) // A4 portrait in points
|
||||||
|
private let margin: CGFloat = 40
|
||||||
|
private let primaryTextColor = UIColor.black
|
||||||
|
private let secondaryTextColor = UIColor.darkGray
|
||||||
|
private let tertiaryTextColor = UIColor.gray
|
||||||
|
private let accentColor = UIColor(red: 0.45, green: 0.34, blue: 0.86, alpha: 1)
|
||||||
|
|
||||||
|
func export(
|
||||||
|
systemName: String,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
sections: [BillOfMaterialsSectionSnapshot]
|
||||||
|
) throws -> URL {
|
||||||
|
let format = UIGraphicsPDFRendererFormat()
|
||||||
|
let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
|
||||||
|
var pageIndex = 1
|
||||||
|
|
||||||
|
let data = renderer.pdfData { context in
|
||||||
|
var cursorY = beginPage(
|
||||||
|
context: context,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
isFirstPage: true
|
||||||
|
)
|
||||||
|
|
||||||
|
if sections.isEmpty {
|
||||||
|
cursorY = ensureSpace(
|
||||||
|
requiredHeight: 60,
|
||||||
|
cursorY: cursorY,
|
||||||
|
context: context,
|
||||||
|
pageIndex: &pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
let emptyMessage = NSLocalizedString(
|
||||||
|
"bom.pdf.placeholder.empty",
|
||||||
|
comment: "Message shown in the PDF export when no components are available"
|
||||||
|
)
|
||||||
|
drawPlaceholder(in: context.cgContext, text: emptyMessage, at: cursorY)
|
||||||
|
} else {
|
||||||
|
for section in sections {
|
||||||
|
let requiredHeight = sectionHeight(for: section)
|
||||||
|
cursorY = ensureSpace(
|
||||||
|
requiredHeight: requiredHeight,
|
||||||
|
cursorY: cursorY,
|
||||||
|
context: context,
|
||||||
|
pageIndex: &pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem
|
||||||
|
)
|
||||||
|
|
||||||
|
cursorY = drawSectionHeader(
|
||||||
|
title: section.title,
|
||||||
|
subtitle: section.subtitle,
|
||||||
|
at: cursorY,
|
||||||
|
in: context.cgContext
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in section.items {
|
||||||
|
cursorY = drawItem(item, at: cursorY, in: context.cgContext)
|
||||||
|
cursorY += 12
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorY += 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFooter(pageIndex: pageIndex, in: context.cgContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("System-BOM-\(UUID().uuidString).pdf")
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func beginPage(
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
pageIndex: Int,
|
||||||
|
systemName: String,
|
||||||
|
unitSystem: UnitSystem,
|
||||||
|
isFirstPage: Bool
|
||||||
|
) -> CGFloat {
|
||||||
|
context.beginPage()
|
||||||
|
let titleFont = UIFont.systemFont(ofSize: isFirstPage ? 26 : 18, weight: .bold)
|
||||||
|
let subtitleFont = UIFont.systemFont(ofSize: isFirstPage ? 16 : 12, weight: .medium)
|
||||||
|
let title = isFirstPage
|
||||||
|
? NSLocalizedString(
|
||||||
|
"bom.pdf.header.title",
|
||||||
|
comment: "Primary title shown at the top of the BOM PDF"
|
||||||
|
)
|
||||||
|
: systemName
|
||||||
|
|
||||||
|
let subtitle: String
|
||||||
|
if isFirstPage {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.pdf.header.subtitle",
|
||||||
|
comment: "Subtitle format combining system name and unit system for the BOM PDF"
|
||||||
|
)
|
||||||
|
subtitle = String(
|
||||||
|
format: format,
|
||||||
|
locale: Locale.current,
|
||||||
|
systemName,
|
||||||
|
unitSystem.displayName
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.pdf.header.inline",
|
||||||
|
comment: "Subtitle describing the active unit system on subsequent PDF pages"
|
||||||
|
)
|
||||||
|
subtitle = String(
|
||||||
|
format: format,
|
||||||
|
locale: Locale.current,
|
||||||
|
unitSystem.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let availableWidth = pageRect.width - (margin * 2)
|
||||||
|
let titleRect = CGRect(x: margin, y: margin, width: availableWidth, height: titleFont.lineHeight + 4)
|
||||||
|
title.draw(in: titleRect, withAttributes: [
|
||||||
|
.font: titleFont,
|
||||||
|
.foregroundColor: primaryTextColor
|
||||||
|
])
|
||||||
|
|
||||||
|
let subtitleRect = CGRect(
|
||||||
|
x: margin,
|
||||||
|
y: titleRect.maxY + 4,
|
||||||
|
width: availableWidth,
|
||||||
|
height: subtitleFont.lineHeight + 2
|
||||||
|
)
|
||||||
|
subtitle.draw(in: subtitleRect, withAttributes: [
|
||||||
|
.font: subtitleFont,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
])
|
||||||
|
|
||||||
|
return subtitleRect.maxY + (isFirstPage ? 24 : 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureSpace(
|
||||||
|
requiredHeight: CGFloat,
|
||||||
|
cursorY: CGFloat,
|
||||||
|
context: UIGraphicsPDFRendererContext,
|
||||||
|
pageIndex: inout Int,
|
||||||
|
systemName: String,
|
||||||
|
unitSystem: UnitSystem
|
||||||
|
) -> CGFloat {
|
||||||
|
if cursorY + requiredHeight <= pageRect.height - margin {
|
||||||
|
return cursorY
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFooter(pageIndex: pageIndex, in: context.cgContext)
|
||||||
|
pageIndex += 1
|
||||||
|
return beginPage(
|
||||||
|
context: context,
|
||||||
|
pageIndex: pageIndex,
|
||||||
|
systemName: systemName,
|
||||||
|
unitSystem: unitSystem,
|
||||||
|
isFirstPage: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionHeaderHeight: CGFloat {
|
||||||
|
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
return headerFont.lineHeight + subtitleFont.lineHeight + 14
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionHeight(for section: BillOfMaterialsSectionSnapshot) -> CGFloat {
|
||||||
|
let itemsHeight = section.items.reduce(0) { partialResult, item in
|
||||||
|
partialResult + itemBlockHeight(for: item) + 12
|
||||||
|
}
|
||||||
|
return sectionHeaderHeight + itemsHeight + 8
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawSectionHeader(title: String, subtitle: String, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
|
||||||
|
var cursorY = yPosition
|
||||||
|
let headerFont = UIFont.systemFont(ofSize: 18, weight: .semibold)
|
||||||
|
let subtitleFont = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||||
|
let availableWidth = pageRect.width - (margin * 2)
|
||||||
|
|
||||||
|
title.draw(
|
||||||
|
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: headerFont.lineHeight + 4),
|
||||||
|
withAttributes: [
|
||||||
|
.font: headerFont,
|
||||||
|
.foregroundColor: primaryTextColor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cursorY += headerFont.lineHeight + 4
|
||||||
|
|
||||||
|
subtitle.draw(
|
||||||
|
in: CGRect(x: margin, y: cursorY, width: availableWidth, height: subtitleFont.lineHeight + 2),
|
||||||
|
withAttributes: [
|
||||||
|
.font: subtitleFont,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
]
|
||||||
|
)
|
||||||
|
cursorY += subtitleFont.lineHeight + 10
|
||||||
|
|
||||||
|
return cursorY
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemBlockHeight(for item: BillOfMaterialsItemSnapshot) -> CGFloat {
|
||||||
|
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
|
||||||
|
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
var height: CGFloat = 0
|
||||||
|
if item.metric != nil {
|
||||||
|
height += metricFont.lineHeight + 2
|
||||||
|
}
|
||||||
|
height += titleFont.lineHeight + 2
|
||||||
|
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
height += detailFont.lineHeight + 4
|
||||||
|
}
|
||||||
|
return height + 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawItem(_ item: BillOfMaterialsItemSnapshot, at yPosition: CGFloat, in context: CGContext) -> CGFloat {
|
||||||
|
let metricFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
|
||||||
|
let titleFont = UIFont.systemFont(ofSize: 14, weight: item.isPrimaryComponent ? .semibold : .medium)
|
||||||
|
let detailFont = UIFont.systemFont(ofSize: 12, weight: .regular)
|
||||||
|
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: titleFont,
|
||||||
|
.foregroundColor: primaryTextColor
|
||||||
|
]
|
||||||
|
let detailAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: detailFont,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
]
|
||||||
|
let metricAttributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: metricFont,
|
||||||
|
.foregroundColor: accentColor
|
||||||
|
]
|
||||||
|
|
||||||
|
let bulletWidth: CGFloat = 6
|
||||||
|
let spacing: CGFloat = 8
|
||||||
|
let availableWidth = pageRect.width - (margin * 2) - bulletWidth - spacing
|
||||||
|
let firstLineHeight = item.metric != nil ? metricFont.lineHeight : titleFont.lineHeight
|
||||||
|
let bulletRect = CGRect(
|
||||||
|
x: margin,
|
||||||
|
y: yPosition + (firstLineHeight / 2) - (bulletWidth / 2),
|
||||||
|
width: bulletWidth,
|
||||||
|
height: bulletWidth
|
||||||
|
)
|
||||||
|
context.setFillColor(accentColor.cgColor)
|
||||||
|
context.fillEllipse(in: bulletRect)
|
||||||
|
|
||||||
|
var cursorY = yPosition
|
||||||
|
let textX = margin + bulletWidth + spacing
|
||||||
|
|
||||||
|
if let metric = item.metric {
|
||||||
|
let metricRect = CGRect(x: textX, y: cursorY, width: availableWidth, height: metricFont.lineHeight + 2)
|
||||||
|
metric.draw(in: metricRect, withAttributes: metricAttributes)
|
||||||
|
cursorY = metricRect.maxY + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleRect = CGRect(
|
||||||
|
x: textX,
|
||||||
|
y: cursorY,
|
||||||
|
width: availableWidth,
|
||||||
|
height: titleFont.lineHeight + 2
|
||||||
|
)
|
||||||
|
item.title.draw(in: titleRect, withAttributes: titleAttributes)
|
||||||
|
cursorY = titleRect.maxY + 2
|
||||||
|
|
||||||
|
if !item.detail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
let detailRect = CGRect(
|
||||||
|
x: textX,
|
||||||
|
y: cursorY,
|
||||||
|
width: availableWidth,
|
||||||
|
height: detailFont.lineHeight + 4
|
||||||
|
)
|
||||||
|
item.detail.draw(in: detailRect, withAttributes: detailAttributes)
|
||||||
|
cursorY = detailRect.maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursorY
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawFooter(pageIndex: Int, in context: CGContext) {
|
||||||
|
let footerFont = UIFont.systemFont(ofSize: 11, weight: .regular)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: footerFont,
|
||||||
|
.foregroundColor: tertiaryTextColor
|
||||||
|
]
|
||||||
|
let format = NSLocalizedString(
|
||||||
|
"bom.pdf.page.number",
|
||||||
|
comment: "Format string for the PDF page number footer"
|
||||||
|
)
|
||||||
|
let text = String(format: format, locale: Locale.current, pageIndex)
|
||||||
|
let size = text.size(withAttributes: attributes)
|
||||||
|
let origin = CGPoint(
|
||||||
|
x: (pageRect.width - size.width) / 2,
|
||||||
|
y: pageRect.height - margin + 10
|
||||||
|
)
|
||||||
|
text.draw(at: origin, withAttributes: attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawPlaceholder(in context: CGContext, text: String, at yPosition: CGFloat) {
|
||||||
|
let font = UIFont.systemFont(ofSize: 14, weight: .regular)
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [
|
||||||
|
.font: font,
|
||||||
|
.foregroundColor: secondaryTextColor
|
||||||
|
]
|
||||||
|
text.draw(
|
||||||
|
in: CGRect(x: margin, y: yPosition, width: pageRect.width - (margin * 2), height: font.lineHeight + 4),
|
||||||
|
withAttributes: attributes
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,9 @@ struct SystemsOnboardingView: View {
|
|||||||
.background(Color(.systemGroupedBackground))
|
.background(Color(.systemGroupedBackground))
|
||||||
.onAppear(perform: resetState)
|
.onAppear(perform: resetState)
|
||||||
.onReceive(timer) { _ in advanceCarousel() }
|
.onReceive(timer) { _ in advanceCarousel() }
|
||||||
|
.task {
|
||||||
|
AnalyticsTracker.log("Launched")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resetState() {
|
private func resetState() {
|
||||||
@@ -102,6 +105,7 @@ struct SystemsOnboardingView: View {
|
|||||||
private func createSystem() {
|
private func createSystem() {
|
||||||
isFieldFocused = false
|
isFieldFocused = false
|
||||||
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = systemName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
AnalyticsTracker.log("System Created", properties: ["name": trimmed])
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
onCreate(trimmed)
|
onCreate(trimmed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,37 +76,16 @@ struct SystemsView: View {
|
|||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
ForEach(systems) { system in
|
ForEach(systems) { system in
|
||||||
NavigationLink(destination: LoadsView(system: system)) {
|
Button {
|
||||||
HStack(spacing: 12) {
|
handleSystemSelection(system)
|
||||||
ZStack {
|
} label: {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
systemRow(for: system)
|
||||||
.fill(Color.componentColor(named: system.colorName))
|
.contentShape(Rectangle())
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(system.name)
|
||||||
|
.accessibilityHint(Text("systems.list.row.accessibility.hint", comment: "Accessibility hint for systems list row"))
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
}
|
}
|
||||||
.onDelete(perform: deleteSystems)
|
.onDelete(perform: deleteSystems)
|
||||||
}
|
}
|
||||||
@@ -117,7 +96,7 @@ struct SystemsView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button {
|
Button {
|
||||||
showingSettings = true
|
openSettings()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
}
|
}
|
||||||
@@ -125,6 +104,7 @@ struct SystemsView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
HStack {
|
HStack {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
|
AnalyticsTracker.log("System Create Navigation")
|
||||||
createNewSystem()
|
createNewSystem()
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
@@ -160,15 +140,98 @@ struct SystemsView: View {
|
|||||||
createOnboardingSystem(named: name)
|
createOnboardingSystem(named: name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func openSettings() {
|
||||||
|
AnalyticsTracker.log("Settings Opened")
|
||||||
|
showingSettings = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSystemSelection(_ system: ElectricalSystem) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"System Opened",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "list"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "list"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func systemRow(for system: ElectricalSystem) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.componentColor(named: 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()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundColor(.secondary.opacity(0.6))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
private func createNewSystem() {
|
private func createNewSystem() {
|
||||||
let system = makeSystem()
|
let system = makeSystem()
|
||||||
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
|
AnalyticsTracker.log(
|
||||||
|
"System Created",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "toolbar"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: true,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "created"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createNewSystem(named name: String) {
|
private func createNewSystem(named name: String) {
|
||||||
let system = makeSystem(preferredName: name)
|
let system = makeSystem(preferredName: name)
|
||||||
navigateToSystem(system, presentSystemEditor: true, loadToOpen: nil)
|
AnalyticsTracker.log(
|
||||||
|
"System Created",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "named"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: true,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "created-named"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createOnboardingSystem(named name: String) {
|
private func createOnboardingSystem(named name: String) {
|
||||||
@@ -176,10 +239,29 @@ struct SystemsView: View {
|
|||||||
preferredName: name,
|
preferredName: name,
|
||||||
colorName: randomSystemColorName()
|
colorName: randomSystemColorName()
|
||||||
)
|
)
|
||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil)
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: nil,
|
||||||
|
source: "onboarding"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func navigateToSystem(_ system: ElectricalSystem, presentSystemEditor: Bool, loadToOpen: SavedLoad?, animated: Bool = true) {
|
private func navigateToSystem(
|
||||||
|
_ system: ElectricalSystem,
|
||||||
|
presentSystemEditor: Bool,
|
||||||
|
loadToOpen: SavedLoad?,
|
||||||
|
animated: Bool = true,
|
||||||
|
source: String = "programmatic"
|
||||||
|
) {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"System Opened",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": source,
|
||||||
|
"loads": loads(for: system).count
|
||||||
|
]
|
||||||
|
)
|
||||||
let target = SystemNavigationTarget(
|
let target = SystemNavigationTarget(
|
||||||
system: system,
|
system: system,
|
||||||
presentSystemEditor: presentSystemEditor,
|
presentSystemEditor: presentSystemEditor,
|
||||||
@@ -228,13 +310,40 @@ struct SystemsView: View {
|
|||||||
hasPerformedInitialAutoNavigation = true
|
hasPerformedInitialAutoNavigation = true
|
||||||
|
|
||||||
guard systems.count == 1, let system = systems.first else { return }
|
guard systems.count == 1, let system = systems.first else { return }
|
||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: nil, animated: false)
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: nil,
|
||||||
|
animated: false,
|
||||||
|
source: "auto"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
private func addComponentFromLibrary(_ item: ComponentLibraryItem) {
|
||||||
let system = makeSystem()
|
let system = makeSystem()
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"System Created",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"source": "library"
|
||||||
|
]
|
||||||
|
)
|
||||||
let load = createLoad(from: item, in: system)
|
let load = createLoad(from: item, in: system)
|
||||||
navigateToSystem(system, presentSystemEditor: false, loadToOpen: load, animated: false)
|
AnalyticsTracker.log(
|
||||||
|
"Library Load Added",
|
||||||
|
properties: [
|
||||||
|
"id": item.id,
|
||||||
|
"name": item.localizedName,
|
||||||
|
"system": system.name
|
||||||
|
]
|
||||||
|
)
|
||||||
|
navigateToSystem(
|
||||||
|
system,
|
||||||
|
presentSystemEditor: false,
|
||||||
|
loadToOpen: load,
|
||||||
|
animated: false,
|
||||||
|
source: "library"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
private func createLoad(from item: ComponentLibraryItem, in system: ElectricalSystem) -> SavedLoad {
|
||||||
@@ -306,9 +415,16 @@ struct SystemsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func deleteSystems(offsets: IndexSet) {
|
private func deleteSystems(offsets: IndexSet) {
|
||||||
|
let systemsToDelete = offsets.map { systems[$0] }
|
||||||
withAnimation {
|
withAnimation {
|
||||||
for index in offsets {
|
for system in systemsToDelete {
|
||||||
let system = systems[index]
|
AnalyticsTracker.log(
|
||||||
|
"System Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": system.name,
|
||||||
|
"loads": loads(for: system).count
|
||||||
|
]
|
||||||
|
)
|
||||||
deleteLoads(for: system)
|
deleteLoads(for: system)
|
||||||
modelContext.delete(system)
|
modelContext.delete(system)
|
||||||
}
|
}
|
||||||
@@ -319,6 +435,14 @@ struct SystemsView: View {
|
|||||||
let descriptor = FetchDescriptor<SavedLoad>()
|
let descriptor = FetchDescriptor<SavedLoad>()
|
||||||
if let loads = try? modelContext.fetch(descriptor) {
|
if let loads = try? modelContext.fetch(descriptor) {
|
||||||
for load in loads where load.system == system {
|
for load in loads where load.system == system {
|
||||||
|
AnalyticsTracker.log(
|
||||||
|
"Load Deleted",
|
||||||
|
properties: [
|
||||||
|
"name": load.name,
|
||||||
|
"system": system.name,
|
||||||
|
"source": "system-delete"
|
||||||
|
]
|
||||||
|
)
|
||||||
modelContext.delete(load)
|
modelContext.delete(load)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,27 +7,44 @@ import Foundation
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
enum UITestSampleData {
|
enum UITestSampleData {
|
||||||
static let argument = "--uitest-sample-data"
|
static let sampleArgument = "--uitest-sample-data"
|
||||||
|
static let resetArgument = "--uitest-reset-data"
|
||||||
|
|
||||||
static func prepareIfNeeded(container: ModelContainer) {
|
static func handleLaunchArguments(container: ModelContainer) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
guard ProcessInfo.processInfo.arguments.contains(argument) else { return }
|
let arguments = ProcessInfo.processInfo.arguments
|
||||||
|
NSLog("UITestSampleData arguments: %@", arguments.joined(separator: ", "))
|
||||||
|
guard arguments.contains(sampleArgument) || arguments.contains(resetArgument) else { return }
|
||||||
|
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try clearExistingData(in: context)
|
if arguments.contains(resetArgument) {
|
||||||
try seedSampleData(in: context)
|
NSLog("UITestSampleData resetting data store")
|
||||||
try context.save()
|
try clearExistingData(in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
if arguments.contains(sampleArgument) {
|
||||||
|
NSLog("UITestSampleData seeding sample data")
|
||||||
|
if !arguments.contains(resetArgument) {
|
||||||
|
try clearExistingData(in: context)
|
||||||
|
}
|
||||||
|
try seedSampleData(in: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.hasChanges {
|
||||||
|
try context.save()
|
||||||
|
NSLog("UITestSampleData save completed")
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
assertionFailure("Failed to seed UI test sample data: \(error)")
|
assertionFailure("Failed to prepare UI test data: \(error)")
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private extension UITestSampleData {
|
extension UITestSampleData {
|
||||||
static func clearExistingData(in context: ModelContext) throws {
|
static func clearExistingData(in context: ModelContext) throws {
|
||||||
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
let systemDescriptor = FetchDescriptor<ElectricalSystem>()
|
||||||
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
let loadDescriptor = FetchDescriptor<SavedLoad>()
|
||||||
|
|||||||
@@ -144,6 +144,29 @@
|
|||||||
"bom.navigation.title.system" = "Stückliste – %@";
|
"bom.navigation.title.system" = "Stückliste – %@";
|
||||||
"bom.size.unknown" = "Größe offen";
|
"bom.size.unknown" = "Größe offen";
|
||||||
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
"bom.terminals.detail" = "Ring- oder Gabelkabelschuhe für %@-Leitungen";
|
||||||
|
"bom.empty.message" = "Dieses System hat noch keine Komponenten.";
|
||||||
|
"bom.export.pdf.button" = "PDF exportieren";
|
||||||
|
"bom.export.pdf.error.title" = "Export fehlgeschlagen";
|
||||||
|
"bom.export.pdf.error.empty" = "Füge vor dem Export mindestens eine Komponente hinzu.";
|
||||||
|
"bom.pdf.header.title" = "System-Stückliste";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Einheitensystem: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "Keine Komponenten verfügbar.";
|
||||||
|
"bom.pdf.page.number" = "Seite %d";
|
||||||
|
"bom.category.components.title" = "Komponenten & Ladegeräte";
|
||||||
|
"bom.category.components.subtitle" = "Hauptverbraucher, Regler und Ladehardware.";
|
||||||
|
"bom.category.batteries.title" = "Batterien";
|
||||||
|
"bom.category.batteries.subtitle" = "Hausspeicher und Batteriebänke.";
|
||||||
|
"bom.category.cables.title" = "Kabel";
|
||||||
|
"bom.category.cables.subtitle" = "Passende Leitungen für jede Strecke.";
|
||||||
|
"bom.category.fuses.title" = "Sicherungen";
|
||||||
|
"bom.category.fuses.subtitle" = "Stromkreisschutz und Halter.";
|
||||||
|
"bom.category.accessories.title" = "Zubehör";
|
||||||
|
"bom.category.accessories.subtitle" = "Sicherungen, Kabelschuhe und weiteres Montagematerial.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"cable.pro.privacy.label" = "Datenschutz";
|
"cable.pro.privacy.label" = "Datenschutz";
|
||||||
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
"cable.pro.privacy.url" = "https://voltplan.app/de/datenschutz";
|
||||||
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
"cable.pro.terms.label" = "Nutzungsbedingungen";
|
||||||
@@ -264,7 +287,7 @@
|
|||||||
"sample.load.charger.name" = "Werkzeugladegerät";
|
"sample.load.charger.name" = "Werkzeugladegerät";
|
||||||
"sample.load.compressor.name" = "Luftkompressor";
|
"sample.load.compressor.name" = "Luftkompressor";
|
||||||
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
"sample.load.fridge.name" = "Kompressor-Kühlschrank";
|
||||||
"sample.load.lighting.name" = "LED-Streifenbeleuchtung";
|
"sample.load.lighting.name" = "LED-Streifen";
|
||||||
"sample.system.rv.location" = "12V Wohnstromkreis";
|
"sample.system.rv.location" = "12V Wohnstromkreis";
|
||||||
"sample.system.rv.name" = "Abenteuer-Van";
|
"sample.system.rv.name" = "Abenteuer-Van";
|
||||||
"sample.system.workshop.location" = "Werkzeugecke";
|
"sample.system.workshop.location" = "Werkzeugecke";
|
||||||
@@ -337,6 +360,7 @@
|
|||||||
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
|
"cable.pro.alert.restored.body" = "Deine bisherigen Käufe sind wieder verfügbar.";
|
||||||
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
|
"cable.pro.alert.error.title" = "Kauf fehlgeschlagen";
|
||||||
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
|
"cable.pro.alert.error.generic" = "Etwas ist schiefgelaufen. Bitte versuche es erneut.";
|
||||||
|
"generic.ok" = "OK";
|
||||||
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
|
"cable.pro.trial.badge" = "Enthält eine %@ Testphase";
|
||||||
"cable.pro.subscription.renews" = "Verlängert sich %@.";
|
"cable.pro.subscription.renews" = "Verlängert sich %@.";
|
||||||
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
|
"cable.pro.subscription.trialThenRenews" = "Testphase, danach Verlängerung %@.";
|
||||||
|
|||||||
@@ -14,6 +14,29 @@
|
|||||||
"bom.navigation.title.system" = "Lista de materiales – %@";
|
"bom.navigation.title.system" = "Lista de materiales – %@";
|
||||||
"bom.size.unknown" = "Tamaño por definir";
|
"bom.size.unknown" = "Tamaño por definir";
|
||||||
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
"bom.terminals.detail" = "Terminales de anillo o de horquilla para cables de %@";
|
||||||
|
"bom.empty.message" = "Todavía no hay componentes guardados en este sistema.";
|
||||||
|
"bom.export.pdf.button" = "Exportar PDF";
|
||||||
|
"bom.export.pdf.error.title" = "Exportación fallida";
|
||||||
|
"bom.export.pdf.error.empty" = "Agrega al menos un componente antes de exportar.";
|
||||||
|
"bom.pdf.header.title" = "Lista de materiales del sistema";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Sistema de unidades: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "No hay componentes disponibles.";
|
||||||
|
"bom.pdf.page.number" = "Página %d";
|
||||||
|
"bom.category.components.title" = "Componentes y cargadores";
|
||||||
|
"bom.category.components.subtitle" = "Dispositivos principales, controladores y equipos de carga.";
|
||||||
|
"bom.category.batteries.title" = "Baterías";
|
||||||
|
"bom.category.batteries.subtitle" = "Bancos domésticos y almacenamiento.";
|
||||||
|
"bom.category.cables.title" = "Cables";
|
||||||
|
"bom.category.cables.subtitle" = "Tendidos dimensionados para cada circuito.";
|
||||||
|
"bom.category.fuses.title" = "Fusibles";
|
||||||
|
"bom.category.fuses.subtitle" = "Protección de circuitos y portafusibles.";
|
||||||
|
"bom.category.accessories.title" = "Accesorios";
|
||||||
|
"bom.category.accessories.subtitle" = "Fusibles, terminales y piezas de soporte.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"component.fallback.name" = "Componente";
|
"component.fallback.name" = "Componente";
|
||||||
"default.load.library" = "Carga de la biblioteca";
|
"default.load.library" = "Carga de la biblioteca";
|
||||||
"default.load.name" = "Mi carga";
|
"default.load.name" = "Mi carga";
|
||||||
@@ -323,3 +346,4 @@
|
|||||||
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
|
"cable.pro.feature.dutyCycle" = "Calculadoras de cables conscientes del ciclo de trabajo";
|
||||||
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
|
"cable.pro.feature.batteryCapacity" = "Configura la capacidad utilizable de la batería";
|
||||||
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
|
"cable.pro.feature.usageBased" = "Cálculos basados en el uso";
|
||||||
|
"generic.ok" = "Aceptar";
|
||||||
|
|||||||
@@ -14,6 +14,29 @@
|
|||||||
"bom.navigation.title.system" = "Liste de matériel – %@";
|
"bom.navigation.title.system" = "Liste de matériel – %@";
|
||||||
"bom.size.unknown" = "Taille à déterminer";
|
"bom.size.unknown" = "Taille à déterminer";
|
||||||
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
"bom.terminals.detail" = "Cosses à œillet ou à fourche adaptées aux câbles de %@";
|
||||||
|
"bom.empty.message" = "Aucun composant enregistré pour ce système pour l’instant.";
|
||||||
|
"bom.export.pdf.button" = "Exporter en PDF";
|
||||||
|
"bom.export.pdf.error.title" = "Échec de l’export";
|
||||||
|
"bom.export.pdf.error.empty" = "Ajoutez au moins un composant avant l’export.";
|
||||||
|
"bom.pdf.header.title" = "Liste de matériaux du système";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Système d’unités : %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "Aucun composant disponible.";
|
||||||
|
"bom.pdf.page.number" = "Page %d";
|
||||||
|
"bom.category.components.title" = "Composants et chargeurs";
|
||||||
|
"bom.category.components.subtitle" = "Appareils principaux, contrôleurs et équipements de charge.";
|
||||||
|
"bom.category.batteries.title" = "Batteries";
|
||||||
|
"bom.category.batteries.subtitle" = "Banques domestiques et stockage.";
|
||||||
|
"bom.category.cables.title" = "Câbles";
|
||||||
|
"bom.category.cables.subtitle" = "Liaisons dimensionnées pour chaque circuit.";
|
||||||
|
"bom.category.fuses.title" = "Fusibles";
|
||||||
|
"bom.category.fuses.subtitle" = "Protection des circuits et porte-fusibles.";
|
||||||
|
"bom.category.accessories.title" = "Accessoires";
|
||||||
|
"bom.category.accessories.subtitle" = "Fusibles, cosses et pièces complémentaires.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"component.fallback.name" = "Composant";
|
"component.fallback.name" = "Composant";
|
||||||
"default.load.library" = "Charge de la bibliothèque";
|
"default.load.library" = "Charge de la bibliothèque";
|
||||||
"default.load.name" = "Ma charge";
|
"default.load.name" = "Ma charge";
|
||||||
@@ -322,4 +345,5 @@
|
|||||||
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
|
"cable.pro.paywall.subtitle" = "Cable PRO offre davantage d'options de configuration pour les charges, les batteries et les chargeurs.";
|
||||||
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
|
"cable.pro.feature.dutyCycle" = "Calculs de câbles tenant compte du cycle d'utilisation";
|
||||||
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
|
"cable.pro.feature.batteryCapacity" = "Configurez la capacité utilisable de la batterie";
|
||||||
"cable.pro.feature.usageBased" = "Calculs basés sur l'utilisation";
|
"cable.pro.feature.usageBased" = "Calculs basés sur l’utilisation";
|
||||||
|
"generic.ok" = "OK";
|
||||||
|
|||||||
@@ -14,6 +14,29 @@
|
|||||||
"bom.navigation.title.system" = "Materiaallijst – %@";
|
"bom.navigation.title.system" = "Materiaallijst – %@";
|
||||||
"bom.size.unknown" = "Afmeting nog onbekend";
|
"bom.size.unknown" = "Afmeting nog onbekend";
|
||||||
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
"bom.terminals.detail" = "Ring- of vorkklemmen geschikt voor %@-bekabeling";
|
||||||
|
"bom.empty.message" = "Er zijn nog geen componenten voor dit systeem opgeslagen.";
|
||||||
|
"bom.export.pdf.button" = "PDF exporteren";
|
||||||
|
"bom.export.pdf.error.title" = "Export mislukt";
|
||||||
|
"bom.export.pdf.error.empty" = "Voeg minimaal één component toe voordat je exporteert.";
|
||||||
|
"bom.pdf.header.title" = "Stuklijst van het systeem";
|
||||||
|
"bom.pdf.header.subtitle" = "%@ • %@";
|
||||||
|
"bom.pdf.header.inline" = "Maateenheid: %@";
|
||||||
|
"bom.pdf.placeholder.empty" = "Geen componenten beschikbaar.";
|
||||||
|
"bom.pdf.page.number" = "Pagina %d";
|
||||||
|
"bom.category.components.title" = "Componenten en laders";
|
||||||
|
"bom.category.components.subtitle" = "Hoofdapparaten, regelaars en laadapparatuur.";
|
||||||
|
"bom.category.batteries.title" = "Batterijen";
|
||||||
|
"bom.category.batteries.subtitle" = "Huishoudbanken en opslag.";
|
||||||
|
"bom.category.cables.title" = "Kabels";
|
||||||
|
"bom.category.cables.subtitle" = "Op maat gemaakte stroomtrajecten per circuit.";
|
||||||
|
"bom.category.fuses.title" = "Zekeringen";
|
||||||
|
"bom.category.fuses.subtitle" = "Circuitbeveiliging en houders.";
|
||||||
|
"bom.category.accessories.title" = "Accessoires";
|
||||||
|
"bom.category.accessories.subtitle" = "Zekeringen, kabelschoenen en ondersteunende onderdelen.";
|
||||||
|
"bom.cable.detail.quantified" = "%1$dx %2$@";
|
||||||
|
"bom.quantity.count.badge" = "%d×";
|
||||||
|
"bom.quantity.length.badge" = "%1$.1f %2$@";
|
||||||
|
"bom.quantity.length.badge.with.spec" = "%1$.1f %2$@ · %3$@";
|
||||||
"component.fallback.name" = "Component";
|
"component.fallback.name" = "Component";
|
||||||
"default.load.library" = "Bibliotheeklast";
|
"default.load.library" = "Bibliotheeklast";
|
||||||
"default.load.name" = "Mijn last";
|
"default.load.name" = "Mijn last";
|
||||||
@@ -323,3 +346,4 @@
|
|||||||
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
|
"cable.pro.feature.dutyCycle" = "Kabelberekeningen die rekening houden met de inschakelduur";
|
||||||
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
|
"cable.pro.feature.batteryCapacity" = "Configureer bruikbare batterijcapaciteit";
|
||||||
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
|
"cable.pro.feature.usageBased" = "Gebruiksgestuurde berekeningen";
|
||||||
|
"generic.ok" = "OK";
|
||||||
|
|||||||
@@ -11,40 +11,75 @@ import Testing
|
|||||||
struct CableTests {
|
struct CableTests {
|
||||||
|
|
||||||
@Test func metricWireSizingUsesNearestStandardSize() async throws {
|
@Test func metricWireSizingUsesNearestStandardSize() async throws {
|
||||||
let calculator = CableCalculator()
|
let crossSection = ElectricalCalculations.recommendedCrossSection(
|
||||||
calculator.voltage = 12
|
length: 10,
|
||||||
calculator.current = 5
|
current: 5,
|
||||||
calculator.length = 10 // meters
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
let crossSection = calculator.recommendedCrossSection(for: .metric)
|
)
|
||||||
#expect(crossSection == 4.0)
|
#expect(crossSection == 4.0)
|
||||||
|
|
||||||
let voltageDrop = calculator.voltageDrop(for: .metric)
|
let voltageDrop = ElectricalCalculations.voltageDrop(
|
||||||
|
length: 10,
|
||||||
|
current: 5,
|
||||||
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
|
)
|
||||||
#expect(abs(voltageDrop - 0.425) < 0.001)
|
#expect(abs(voltageDrop - 0.425) < 0.001)
|
||||||
|
|
||||||
let dropPercentage = calculator.voltageDropPercentage(for: .metric)
|
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: 10,
|
||||||
|
current: 5,
|
||||||
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
|
)
|
||||||
#expect(abs(dropPercentage - 3.5417) < 0.001)
|
#expect(abs(dropPercentage - 3.5417) < 0.001)
|
||||||
|
|
||||||
let powerLoss = calculator.powerLoss(for: .metric)
|
let powerLoss = ElectricalCalculations.powerLoss(
|
||||||
|
length: 10,
|
||||||
|
current: 5,
|
||||||
|
voltage: 12,
|
||||||
|
unitSystem: .metric
|
||||||
|
)
|
||||||
#expect(abs(powerLoss - 2.125) < 0.001)
|
#expect(abs(powerLoss - 2.125) < 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
|
@Test func imperialWireSizingMatchesExpectedGauge() async throws {
|
||||||
let calculator = CableCalculator()
|
let awg = ElectricalCalculations.recommendedCrossSection(
|
||||||
calculator.voltage = 120
|
length: 25,
|
||||||
calculator.current = 15
|
current: 15,
|
||||||
calculator.length = 25 // feet
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
let awg = calculator.recommendedCrossSection(for: .imperial)
|
)
|
||||||
#expect(awg == 18.0)
|
#expect(awg == 18.0)
|
||||||
|
|
||||||
let voltageDrop = calculator.voltageDrop(for: .imperial)
|
let voltageDrop = ElectricalCalculations.voltageDrop(
|
||||||
|
length: 25,
|
||||||
|
current: 15,
|
||||||
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
|
)
|
||||||
#expect(abs(voltageDrop - 4.722) < 0.01)
|
#expect(abs(voltageDrop - 4.722) < 0.01)
|
||||||
|
|
||||||
let dropPercentage = calculator.voltageDropPercentage(for: .imperial)
|
let dropPercentage = ElectricalCalculations.voltageDropPercentage(
|
||||||
|
length: 25,
|
||||||
|
current: 15,
|
||||||
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
|
)
|
||||||
#expect(abs(dropPercentage - 3.935) < 0.01)
|
#expect(abs(dropPercentage - 3.935) < 0.01)
|
||||||
|
|
||||||
let powerLoss = calculator.powerLoss(for: .imperial)
|
let powerLoss = ElectricalCalculations.powerLoss(
|
||||||
|
length: 25,
|
||||||
|
current: 15,
|
||||||
|
voltage: 120,
|
||||||
|
unitSystem: .imperial
|
||||||
|
)
|
||||||
#expect(abs(powerLoss - 70.83) < 0.05)
|
#expect(abs(powerLoss - 70.83) < 0.05)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func recommendedFuseRoundsUpToNearestStandardSize() async throws {
|
||||||
|
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 7.2) == 10)
|
||||||
|
#expect(ElectricalCalculations.recommendedFuse(forCurrent: 59.0) == 80)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
@testable import Cable
|
@testable import Cable
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let german = Locale(identifier: "de_DE")
|
let german = Foundation.Locale(identifier: "de_DE")
|
||||||
#expect(item.localizedName(for: german) == "Ankerwinde")
|
#expect(item.localizedName(for: german) == "Ankerwinde")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let french = Locale(identifier: "fr_FR")
|
let french = Foundation.Locale(identifier: "fr_FR")
|
||||||
#expect(item.localizedName(for: french) == "Anchor Winch")
|
#expect(item.localizedName(for: french) == "Anchor Winch")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let spanishMexico = Locale(identifier: "es_MX")
|
let spanishMexico = Foundation.Locale(identifier: "es_MX")
|
||||||
#expect(item.localizedName(for: spanishMexico) == "Molinete")
|
#expect(item.localizedName(for: spanishMexico) == "Molinete")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let germanSwitzerland = Locale(identifier: "de_CH")
|
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
|
||||||
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
|
#expect(item.localizedName(for: germanSwitzerland) == "Ankerwinde")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +99,7 @@ struct ComponentLibraryItemTests {
|
|||||||
affiliateLinks: []
|
affiliateLinks: []
|
||||||
)
|
)
|
||||||
|
|
||||||
let french = Locale(identifier: "fr_FR")
|
let french = Foundation.Locale(identifier: "fr_FR")
|
||||||
#expect(item.localizedName(for: french) == "Guindeau")
|
#expect(item.localizedName(for: french) == "Guindeau")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,536 @@
|
|||||||
//
|
|
||||||
// CableUITestsScreenshot.swift
|
|
||||||
// CableUITestsScreenshot
|
|
||||||
//
|
|
||||||
// Created by Stefan Lange-Hegermann on 06.10.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class CableUITestsScreenshot: XCTestCase {
|
final class CableUITestsScreenshot: XCTestCase {
|
||||||
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
|
||||||
|
private enum UIStringKey: String {
|
||||||
|
case addLoad
|
||||||
|
case browseLibrary
|
||||||
|
case library
|
||||||
|
case overviewTab
|
||||||
|
case componentsTab
|
||||||
|
case batteriesTab
|
||||||
|
case chargersTab
|
||||||
|
case close
|
||||||
|
case cancel
|
||||||
|
case settings
|
||||||
|
case defaultLoadName
|
||||||
|
case billOfMaterials
|
||||||
|
case systemEditorTitle
|
||||||
|
case systemsTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
private let translations: [UIStringKey: [String: String]] = [
|
||||||
|
.addLoad: [
|
||||||
|
"en": "Add Load",
|
||||||
|
"de": "Verbraucher hinzufügen",
|
||||||
|
"es": "Añadir carga",
|
||||||
|
"fr": "Ajouter une charge",
|
||||||
|
"nl": "Belasting toevoegen",
|
||||||
|
],
|
||||||
|
.browseLibrary: [
|
||||||
|
"en": "Browse Library",
|
||||||
|
"de": "Bibliothek durchsuchen",
|
||||||
|
"es": "Explorar biblioteca",
|
||||||
|
"fr": "Parcourir la bibliothèque",
|
||||||
|
"nl": "Bibliotheek bekijken",
|
||||||
|
],
|
||||||
|
.library: [
|
||||||
|
"en": "Library",
|
||||||
|
"de": "Bibliothek",
|
||||||
|
"es": "Biblioteca",
|
||||||
|
"fr": "Bibliothèque",
|
||||||
|
"nl": "Bibliotheek",
|
||||||
|
],
|
||||||
|
.overviewTab: [
|
||||||
|
"en": "Overview",
|
||||||
|
"de": "Übersicht",
|
||||||
|
"es": "Resumen",
|
||||||
|
"fr": "Aperçu",
|
||||||
|
"nl": "Overzicht",
|
||||||
|
],
|
||||||
|
.componentsTab: [
|
||||||
|
"en": "Components",
|
||||||
|
"de": "Verbraucher",
|
||||||
|
"es": "Componentes",
|
||||||
|
"fr": "Composants",
|
||||||
|
"nl": "Componenten",
|
||||||
|
],
|
||||||
|
.batteriesTab: [
|
||||||
|
"en": "Batteries",
|
||||||
|
"de": "Batterien",
|
||||||
|
"es": "Baterías",
|
||||||
|
"fr": "Batteries",
|
||||||
|
"nl": "Batterijen",
|
||||||
|
],
|
||||||
|
.chargersTab: [
|
||||||
|
"en": "Chargers",
|
||||||
|
"de": "Ladegeräte",
|
||||||
|
"es": "Cargadores",
|
||||||
|
"fr": "Chargeurs",
|
||||||
|
"nl": "Laders",
|
||||||
|
],
|
||||||
|
.close: [
|
||||||
|
"en": "Close",
|
||||||
|
"de": "Schließen",
|
||||||
|
"es": "Cerrar",
|
||||||
|
"fr": "Fermer",
|
||||||
|
"nl": "Sluiten",
|
||||||
|
],
|
||||||
|
.cancel: [
|
||||||
|
"en": "Cancel",
|
||||||
|
"de": "Abbrechen",
|
||||||
|
"es": "Cancelar",
|
||||||
|
"fr": "Annuler",
|
||||||
|
"nl": "Annuleren",
|
||||||
|
],
|
||||||
|
.settings: [
|
||||||
|
"en": "Settings",
|
||||||
|
"de": "Einstellungen",
|
||||||
|
"es": "Configuración",
|
||||||
|
"fr": "Réglages",
|
||||||
|
"nl": "Instellingen",
|
||||||
|
],
|
||||||
|
.defaultLoadName: [
|
||||||
|
"en": "New Load",
|
||||||
|
"de": "Neuer Verbraucher",
|
||||||
|
"es": "Carga nueva",
|
||||||
|
"fr": "Nouvelle charge",
|
||||||
|
"nl": "Nieuwe last",
|
||||||
|
],
|
||||||
|
.billOfMaterials: [
|
||||||
|
"en": "Bill of Materials",
|
||||||
|
"de": "Stückliste",
|
||||||
|
"es": "Lista de materiales",
|
||||||
|
"fr": "Liste de matériel",
|
||||||
|
"nl": "Stuklijst",
|
||||||
|
],
|
||||||
|
.systemEditorTitle: [
|
||||||
|
"en": "Edit System",
|
||||||
|
"de": "System bearbeiten",
|
||||||
|
"es": "Editar sistema",
|
||||||
|
"fr": "Modifier le système",
|
||||||
|
"nl": "Systeem bewerken",
|
||||||
|
],
|
||||||
|
.systemsTitle: [
|
||||||
|
"en": "Systems",
|
||||||
|
"de": "Systeme",
|
||||||
|
"es": "Sistemas",
|
||||||
|
"fr": "Systèmes",
|
||||||
|
"nl": "Systemen",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
continueAfterFailure = false
|
|
||||||
try super.setUpWithError()
|
try super.setUpWithError()
|
||||||
ensureDoNotDisturbEnabled()
|
continueAfterFailure = false
|
||||||
dismissSystemOverlays()
|
XCUIDevice.shared.orientation = .portrait
|
||||||
|
//ensureDoNotDisturbEnabled()
|
||||||
|
//dismissSystemOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDownWithError() throws {
|
override func tearDownWithError() throws {
|
||||||
try super.tearDownWithError()
|
try super.tearDownWithError()
|
||||||
dismissSystemOverlays()
|
//dismissSystemOverlays()
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testExample() throws {
|
func testOnboardingScreenshots() throws {
|
||||||
|
let app = launchApp(arguments: ["--uitest-reset-data"])
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
|
||||||
|
let createSystemButton = app.buttons["create-system-button"]
|
||||||
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
dismissNotificationBannersIfNeeded()
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "01-OnboardingSystemsView")
|
||||||
|
|
||||||
|
createSystemButton.tap()
|
||||||
|
|
||||||
|
let addLoadButton = button(in: app.buttons, for: .addLoad)
|
||||||
|
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 8))
|
||||||
|
let browseLibraryButton = button(in: app.buttons, for: .browseLibrary)
|
||||||
|
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 4))
|
||||||
|
|
||||||
|
waitForStability()
|
||||||
|
takeScreenshot(named: "02-OnboardingSystemView")
|
||||||
|
|
||||||
|
browseLibraryButton.tap()
|
||||||
|
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||||
|
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "04-ComponentSelectorView")
|
||||||
|
libraryCloseButton.tap()
|
||||||
|
|
||||||
|
XCTAssertTrue(addLoadButton.waitForExistence(timeout: 4))
|
||||||
|
addLoadButton.tap()
|
||||||
|
|
||||||
|
let newLoadButton = button(in: app.buttons, for: .defaultLoadName)
|
||||||
|
XCTAssertTrue(newLoadButton.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "03-LoadEditorView")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testSampleDataScreenshots() throws {
|
||||||
let app = XCUIApplication()
|
let app = XCUIApplication()
|
||||||
|
app.launchArguments = ["--uitest-reset-data", "--uitest-sample-data"]
|
||||||
app.launch()
|
app.launch()
|
||||||
dismissSystemOverlays()
|
|
||||||
|
let systemsList = resolvedSystemsList(in: app)
|
||||||
|
XCTAssertTrue(systemsList.waitForExistence(timeout: 4))
|
||||||
|
takeScreenshot(named: "05-SystemsWithSampleData")
|
||||||
|
|
||||||
|
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 2))
|
||||||
|
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
||||||
|
|
||||||
|
let systemButton = firstSystemCell.buttons.firstMatch
|
||||||
|
if systemButton.exists {
|
||||||
|
systemButton.tap()
|
||||||
|
} else {
|
||||||
|
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
var detailVisible = waitForSystemDetail(named: systemName, in: app)
|
||||||
|
if !detailVisible {
|
||||||
|
firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
detailVisible = waitForSystemDetail(named: systemName, in: app)
|
||||||
|
}
|
||||||
|
XCTAssertTrue(detailVisible)
|
||||||
|
takeScreenshot(named: "06-AdventureVanOverview")
|
||||||
|
|
||||||
|
// let overviewTab = app.buttons["overview-tab"]
|
||||||
|
// XCTAssertTrue(overviewTab.waitForExistence(timeout: 3))
|
||||||
|
// overviewTab.tap()
|
||||||
|
waitForStability(long: false)
|
||||||
|
let bomElement = resolveBillOfMaterialsElement(in: app)
|
||||||
|
|
||||||
|
if !bomElement.waitForExistence(timeout: 6) {
|
||||||
|
bringElementIntoView(bomElement, in: app)
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(bomElement.exists)
|
||||||
|
|
||||||
|
if !bomElement.isHittable {
|
||||||
|
bringElementIntoView(bomElement, in: app, requireHittable: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bomElement.isHittable {
|
||||||
|
bomElement.tap()
|
||||||
|
} else {
|
||||||
|
bomElement.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
waitForStability(long: true)
|
||||||
|
takeScreenshot(named: "08-BillOfMaterials")
|
||||||
|
|
||||||
|
let closeButton = app.buttons["system-bom-close-button"]
|
||||||
|
XCTAssertTrue(closeButton.waitForExistence(timeout: 6))
|
||||||
|
closeButton.tap()
|
||||||
|
|
||||||
|
let componentsTab = componentsTabButton(in: app)
|
||||||
|
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
|
||||||
|
if componentsTab.isHittable {
|
||||||
|
componentsTab.tap()
|
||||||
|
} else {
|
||||||
|
componentsTab.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadsList = resolvedLoadsList(in: app)
|
||||||
|
XCTAssertTrue(loadsList.waitForExistence(timeout: 4))
|
||||||
|
takeScreenshot(named: "07-AdventureVanLoads")
|
||||||
|
|
||||||
|
waitForStability()
|
||||||
|
let firstLoad = loadsList.cells.element(boundBy: 0)
|
||||||
|
XCTAssertTrue(firstLoad.waitForExistence(timeout: 2))
|
||||||
|
let loadName = firstLoad.staticTexts.firstMatch.label
|
||||||
|
firstLoad.tap()
|
||||||
|
|
||||||
|
let loadNavButton = app.navigationBars.buttons[loadName]
|
||||||
|
XCTAssertTrue(loadNavButton.waitForExistence(timeout: 3))
|
||||||
|
takeScreenshot(named: "09-AdventureVanCalculator")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func launchApp(arguments: [String]) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
var launchArguments = ["--uitest-reset-data"]
|
||||||
|
launchArguments.append(contentsOf: arguments)
|
||||||
|
app.launchArguments = launchArguments
|
||||||
|
app.launch()
|
||||||
|
//dismissSystemOverlays()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["systems-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.collectionViews.firstMatch.waitForExistence(timeout: 2) {
|
||||||
|
return app.collectionViews.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["systems-list"]
|
||||||
|
if table.waitForExistence(timeout: 6) {
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertTrue(app.tables.firstMatch.waitForExistence(timeout: 2))
|
||||||
|
return app.tables.firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["loads-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["loads-list"]
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
private func takeScreenshot(named name: String) {
|
||||||
|
let screenshot = XCUIScreen.main.screenshot()
|
||||||
|
let attachment = XCTAttachment(screenshot: screenshot)
|
||||||
|
attachment.name = name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForStability(long: Bool = false) {
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(long ? 5.0 : 0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let identifierMatch = app.descendants(matching: .any)
|
||||||
|
.matching(identifier: "components-tab").firstMatch
|
||||||
|
if identifierMatch.exists {
|
||||||
|
return identifierMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
let localizedLabels = [
|
||||||
|
"Components", "Verbraucher", "Componentes", "Composants", "Componenten"
|
||||||
|
]
|
||||||
|
for label in localizedLabels {
|
||||||
|
let button = app.buttons[label]
|
||||||
|
if button.exists {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabBarButton = app.tabBars.buttons[label]
|
||||||
|
if tabBarButton.exists {
|
||||||
|
return tabBarButton
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentedButton = app.segmentedControls.buttons[label]
|
||||||
|
if segmentedButton.exists {
|
||||||
|
return segmentedButton
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentedOther = app.segmentedControls.otherElements[label]
|
||||||
|
if segmentedOther.exists {
|
||||||
|
return segmentedOther
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fallbackSegmented = app.segmentedControls.buttons.element(boundBy: 1)
|
||||||
|
if fallbackSegmented.exists {
|
||||||
|
return fallbackSegmented
|
||||||
|
}
|
||||||
|
|
||||||
|
let tabBarButton = app.tabBars.buttons.element(boundBy: 1)
|
||||||
|
if tabBarButton.exists {
|
||||||
|
return tabBarButton
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.tabBars.descendants(matching: .any).firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitForSystemDetail(named systemName: String, in app: XCUIApplication, timeout: TimeInterval = 6) -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if app.otherElements["system-overview"].exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let navBar = app.navigationBars.firstMatch
|
||||||
|
if navBar.buttons[systemName].exists || navBar.staticTexts[systemName].exists {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
|
||||||
|
}
|
||||||
|
|
||||||
|
return app.otherElements["system-overview"].exists
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bringElementIntoView(
|
||||||
|
_ element: XCUIElement,
|
||||||
|
in app: XCUIApplication,
|
||||||
|
requireHittable: Bool = false,
|
||||||
|
attempts: Int = 8
|
||||||
|
) {
|
||||||
|
let scrollContainer = app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.tables.firstMatch
|
||||||
|
for _ in 0..<attempts {
|
||||||
|
if element.exists, (!requireHittable || element.isHittable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if scrollContainer.exists {
|
||||||
|
scrollContainer.swipeUp()
|
||||||
|
} else {
|
||||||
|
app.swipeUp()
|
||||||
|
}
|
||||||
|
waitForStability()
|
||||||
|
_ = element.waitForExistence(timeout: 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveBillOfMaterialsElement(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let identifier = "system-bom-button"
|
||||||
|
let buttonByIdentifier = app.buttons.matching(identifier: identifier).firstMatch
|
||||||
|
if buttonByIdentifier.exists { return buttonByIdentifier }
|
||||||
|
|
||||||
|
let elementByIdentifier = app.otherElements.matching(identifier: identifier).firstMatch
|
||||||
|
if elementByIdentifier.exists { return elementByIdentifier }
|
||||||
|
|
||||||
|
let candidates = candidateStrings(for: .billOfMaterials)
|
||||||
|
for candidate in candidates {
|
||||||
|
let button = app.buttons[candidate]
|
||||||
|
if button.exists {
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
let other = app.otherElements[candidate]
|
||||||
|
if other.exists {
|
||||||
|
return other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buttonByIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissNotificationBannersIfNeeded() {
|
||||||
|
let banner = springboard.otherElements.matching(identifier: "NotificationShortLookView").firstMatch
|
||||||
|
if banner.waitForExistence(timeout: 1) {
|
||||||
|
if banner.isHittable {
|
||||||
|
banner.swipeUp()
|
||||||
|
} else {
|
||||||
|
let start = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
let end = banner.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: -1.5))
|
||||||
|
start.press(forDuration: 0.05, thenDragTo: end)
|
||||||
|
}
|
||||||
|
waitForStability()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func candidateStrings(for key: UIStringKey) -> [String] {
|
||||||
|
var values = Set<String>()
|
||||||
|
if let languageCode = Locale.preferredLanguages.first.flatMap({ String($0.prefix(2)) }),
|
||||||
|
let localized = translations[key]?[languageCode] {
|
||||||
|
values.insert(localized)
|
||||||
|
}
|
||||||
|
if let english = translations[key]?["en"] {
|
||||||
|
values.insert(english)
|
||||||
|
}
|
||||||
|
if let others = translations[key]?.values {
|
||||||
|
values.formUnion(others)
|
||||||
|
}
|
||||||
|
if key == .settings {
|
||||||
|
values.insert("gearshape")
|
||||||
|
}
|
||||||
|
return Array(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func button(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement {
|
||||||
|
let candidates = candidateStrings(for: key)
|
||||||
|
for candidate in candidates {
|
||||||
|
let element = query[candidate]
|
||||||
|
if element.exists {
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let predicate = NSPredicate(
|
||||||
|
format: "label IN %@ OR identifier IN %@",
|
||||||
|
NSArray(array: candidates),
|
||||||
|
NSArray(array: candidates)
|
||||||
|
)
|
||||||
|
return query.matching(predicate).firstMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionalButton(in query: XCUIElementQuery, for key: UIStringKey) -> XCUIElement? {
|
||||||
|
let element = button(in: query, for: key)
|
||||||
|
return element.exists ? element : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tabButton(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
|
||||||
|
let tabSpecific = button(in: app.tabBars.buttons, for: key)
|
||||||
|
if tabSpecific.exists {
|
||||||
|
return tabSpecific
|
||||||
|
}
|
||||||
|
return button(in: app.buttons, for: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigationBar(in app: XCUIApplication, key: UIStringKey) -> XCUIElement {
|
||||||
|
let candidates = candidateStrings(for: key)
|
||||||
|
for candidate in candidates {
|
||||||
|
let bar = app.navigationBars[candidate]
|
||||||
|
if bar.exists {
|
||||||
|
return bar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return app.navigationBars.element(boundBy: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapButtonIfPresent(app: XCUIApplication, key: UIStringKey) {
|
||||||
|
let candidates = candidateStrings(for: key)
|
||||||
|
for candidate in candidates {
|
||||||
|
let button = app.buttons[candidate]
|
||||||
|
if button.waitForExistence(timeout: 2) {
|
||||||
|
button.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openBillOfMaterials(app: XCUIApplication) {
|
||||||
|
let bomButton = button(in: app.buttons, for: .billOfMaterials)
|
||||||
|
XCTAssertTrue(bomButton.waitForExistence(timeout: 6))
|
||||||
|
bomButton.tap()
|
||||||
|
let bomView = app.otherElements["system-bom-view"]
|
||||||
|
XCTAssertTrue(bomView.waitForExistence(timeout: 8))
|
||||||
|
waitForStability(long: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func closeBillOfMaterials(app: XCUIApplication) {
|
||||||
|
tapButtonIfPresent(app: app, key: .close)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func navigateBack(app: XCUIApplication) {
|
||||||
|
let backButton = app.navigationBars.buttons.element(boundBy: 0)
|
||||||
|
if backButton.exists {
|
||||||
|
backButton.tap()
|
||||||
|
} else {
|
||||||
|
app.swipeRight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSettings(app: XCUIApplication) {
|
||||||
|
let systemsBar = navigationBar(in: app, key: .systemsTitle)
|
||||||
|
let settingsButton = button(in: systemsBar.buttons, for: .settings)
|
||||||
|
if settingsButton.exists {
|
||||||
|
settingsButton.tap()
|
||||||
|
} else {
|
||||||
|
systemsBar.buttons.element(boundBy: 0).tap()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureDoNotDisturbEnabled() {
|
private func ensureDoNotDisturbEnabled() {
|
||||||
@@ -41,6 +545,8 @@ final class CableUITestsScreenshot: XCTestCase {
|
|||||||
focusTile.press(forDuration: 1.0)
|
focusTile.press(forDuration: 1.0)
|
||||||
} else if focusButton.waitForExistence(timeout: 2) {
|
} else if focusButton.waitForExistence(timeout: 2) {
|
||||||
focusButton.press(forDuration: 1.0)
|
focusButton.press(forDuration: 1.0)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let dndButton = springboard.buttons["Do Not Disturb"]
|
let dndButton = springboard.buttons["Do Not Disturb"]
|
||||||
|
|||||||
@@ -8,7 +8,37 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
||||||
|
private func launchApp(arguments: [String] = []) -> XCUIApplication {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
var launchArguments = ["--uitest-reset-data"]
|
||||||
|
launchArguments.append(contentsOf: arguments)
|
||||||
|
app.launchArguments = launchArguments
|
||||||
|
app.launch()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedSystemsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["systems-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["systems-list"]
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedLoadsList(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let collection = app.collectionViews["loads-list"]
|
||||||
|
if collection.waitForExistence(timeout: 6) {
|
||||||
|
return collection
|
||||||
|
}
|
||||||
|
|
||||||
|
let table = app.tables["loads-list"]
|
||||||
|
XCTAssertTrue(table.waitForExistence(timeout: 6))
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private func takeScreenshot(name: String,
|
private func takeScreenshot(name: String,
|
||||||
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
lifetime: XCTAttachment.Lifetime = .keepAlways) {
|
||||||
@@ -30,66 +60,63 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
func testOnboardingLoadsView() throws {
|
func testOnboardingLoadsView() throws {
|
||||||
let app = XCUIApplication()
|
let app = launchApp(arguments: ["--uitest-reset-data"])
|
||||||
|
|
||||||
app.launch()
|
|
||||||
takeScreenshot(name: "01-OnboardingSystemsView")
|
takeScreenshot(name: "01-OnboardingSystemsView")
|
||||||
|
|
||||||
let createSystemButton = app.buttons["create-system-button"]
|
let createSystemButton = app.buttons["create-system-button"]
|
||||||
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(createSystemButton.waitForExistence(timeout: 5))
|
||||||
createSystemButton.tap()
|
createSystemButton.tap()
|
||||||
takeScreenshot(name: "02-OnboardingLoadsView")
|
takeScreenshot(name: "02-OnboardingLoadsView")
|
||||||
|
|
||||||
|
let componentsTab = app.buttons["components-tab"]
|
||||||
|
XCTAssertTrue(componentsTab.waitForExistence(timeout: 3))
|
||||||
|
componentsTab.tap()
|
||||||
|
|
||||||
let libraryCloseButton = app.buttons["library-view-close-button"]
|
let libraryCloseButton = app.buttons["library-view-close-button"]
|
||||||
let selectComponentButton = app.buttons["select-component-button"]
|
let browseLibraryButton = onboardingSecondaryButton(in: app)
|
||||||
XCTAssertTrue(selectComponentButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(browseLibraryButton.waitForExistence(timeout: 5))
|
||||||
selectComponentButton.tap()
|
browseLibraryButton.tap()
|
||||||
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(libraryCloseButton.waitForExistence(timeout: 5))
|
||||||
Thread.sleep(forTimeInterval: 10)
|
Thread.sleep(forTimeInterval: 10)
|
||||||
takeScreenshot(name: "04-ComponentSelectorView")
|
takeScreenshot(name: "04-ComponentSelectorView")
|
||||||
libraryCloseButton.tap()
|
libraryCloseButton.tap()
|
||||||
|
|
||||||
let createComponentButton = app.buttons["create-component-button"]
|
let createComponentButton = onboardingPrimaryButton(in: app)
|
||||||
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
XCTAssertTrue(createComponentButton.waitForExistence(timeout: 5))
|
||||||
createComponentButton.tap()
|
createComponentButton.tap()
|
||||||
takeScreenshot(name: "03-LoadEditorView")
|
takeScreenshot(name: "03-LoadEditorView")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testWithSampleData() throws {
|
func testWithSampleData() throws {
|
||||||
let app = XCUIApplication()
|
let app = launchApp(arguments: ["--uitest-sample-data"])
|
||||||
app.launchArguments.append("--uitest-sample-data")
|
|
||||||
app.launch()
|
|
||||||
|
|
||||||
let systemsCollection = app.collectionViews.firstMatch
|
let systemsList = resolvedSystemsList(in: app)
|
||||||
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)
|
let firstSystemCell = systemsList.cells.element(boundBy: 0)
|
||||||
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
XCTAssertTrue(firstSystemCell.waitForExistence(timeout: 3))
|
||||||
|
let systemName = firstSystemCell.staticTexts.firstMatch.label
|
||||||
|
|
||||||
takeScreenshot(name: "05-SystemsWithSampleData")
|
takeScreenshot(name: "05-SystemsWithSampleData")
|
||||||
|
|
||||||
firstSystemCell.tap()
|
let rowButton = firstSystemCell.buttons.firstMatch
|
||||||
|
if rowButton.waitForExistence(timeout: 2) {
|
||||||
let loadsCollection = app.collectionViews["loads-list"]
|
rowButton.tap()
|
||||||
let loadsTable = app.tables["loads-list"]
|
|
||||||
|
|
||||||
let loadsElement: XCUIElement
|
|
||||||
if loadsCollection.waitForExistence(timeout: 3) {
|
|
||||||
loadsElement = loadsCollection
|
|
||||||
} else {
|
} else {
|
||||||
XCTAssertTrue(loadsTable.waitForExistence(timeout: 3))
|
firstSystemCell.tap()
|
||||||
loadsElement = loadsTable
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let navButton = app.navigationBars.buttons[systemName]
|
||||||
|
if !navButton.waitForExistence(timeout: 3) {
|
||||||
|
let coordinate = firstSystemCell.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
|
||||||
|
coordinate.tap()
|
||||||
|
XCTAssertTrue(navButton.waitForExistence(timeout: 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
tapComponentsTab(in: app)
|
||||||
|
|
||||||
|
let loadsElement = resolvedLoadsList(in: app)
|
||||||
|
XCTAssertTrue(loadsElement.waitForExistence(timeout: 6))
|
||||||
|
|
||||||
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
XCTAssertTrue(loadsElement.cells.firstMatch.waitForExistence(timeout: 3))
|
||||||
Thread.sleep(forTimeInterval: 1)
|
Thread.sleep(forTimeInterval: 1)
|
||||||
takeScreenshot(name: "06-AdventureVanLoads")
|
takeScreenshot(name: "06-AdventureVanLoads")
|
||||||
@@ -98,9 +125,42 @@ final class CableUITestsScreenshotLaunchTests: XCTestCase {
|
|||||||
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
XCTAssertTrue(bomButton.waitForExistence(timeout: 2))
|
||||||
bomButton.tap()
|
bomButton.tap()
|
||||||
|
|
||||||
let bomView = app.otherElements["system-bom-view"]
|
// let bomView = app.otherElements["system-bom-view"]
|
||||||
XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
// XCTAssertTrue(bomView.waitForExistence(timeout: 3))
|
||||||
Thread.sleep(forTimeInterval: 1)
|
//
|
||||||
takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
// Thread.sleep(forTimeInterval: 1)
|
||||||
|
// takeScreenshot(name: "07-AdventureVanBillOfMaterials")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapComponentsTab(in app: XCUIApplication) {
|
||||||
|
let button = componentsTabButton(in: app)
|
||||||
|
XCTAssertTrue(button.waitForExistence(timeout: 3))
|
||||||
|
button.tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onboardingPrimaryButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let button = app.buttons["create-component-button"]
|
||||||
|
if button.exists { return button }
|
||||||
|
return app.buttons["onboarding-primary-button"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onboardingSecondaryButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let button = app.buttons["select-component-button"]
|
||||||
|
if button.exists { return button }
|
||||||
|
return app.buttons["onboarding-secondary-button"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func componentsTabButton(in app: XCUIApplication) -> XCUIElement {
|
||||||
|
let idButton = app.buttons["components-tab"]
|
||||||
|
if idButton.exists {
|
||||||
|
return idButton
|
||||||
|
}
|
||||||
|
|
||||||
|
let labels = ["Components", "Verbraucher", "Componentes", "Composants", "Componenten"]
|
||||||
|
for label in labels {
|
||||||
|
let button = app.buttons[label]
|
||||||
|
if button.exists { return button }
|
||||||
|
}
|
||||||
|
return app.tabBars.buttons.element(boundBy: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
Podfile
Normal file
22
Podfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Uncomment the next line to define a global platform for your project
|
||||||
|
# platform :ios, '9.0'
|
||||||
|
platform :ios, '17.6'
|
||||||
|
target 'Cable' do
|
||||||
|
# Comment the next line if you don't want to use dynamic frameworks
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
# Pods for Cable
|
||||||
|
target 'CableTests' do
|
||||||
|
inherit! :search_paths
|
||||||
|
# Pods for testing
|
||||||
|
end
|
||||||
|
|
||||||
|
target 'CableUITests' do
|
||||||
|
# Pods for testing
|
||||||
|
end
|
||||||
|
|
||||||
|
target 'CableUITestsScreenshot' do
|
||||||
|
# Pods for testing
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -15,8 +15,8 @@ is_truthy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DEVICE_MATRIX=(
|
DEVICE_MATRIX=(
|
||||||
|
# "iPhone 17 Pro Max|26.0|iphone-17-pro-max"
|
||||||
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
|
"iPad Pro Screenshot|26.0|ipad-pro-13-inch-m4"
|
||||||
"iPhone 17 Pro Max|26.0|iphone-17-pro-max"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
command -v xcparse >/dev/null 2>&1 || {
|
command -v xcparse >/dev/null 2>&1 || {
|
||||||
@@ -83,6 +83,7 @@ for device_entry in "${DEVICE_MATRIX[@]}"; do
|
|||||||
--batteryState charged --batteryLevel 100 \
|
--batteryState charged --batteryLevel 100 \
|
||||||
--wifiBars 3
|
--wifiBars 3
|
||||||
|
|
||||||
|
xcrun simctl spawn "$UDID" defaults write com.apple.springboard DoNotDisturb -bool true
|
||||||
|
|
||||||
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
bundle="results-${DEVICE_SLUG}-${lang}.xcresult"
|
||||||
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
outdir="Shots/Screenshots/${DEVICE_SLUG}/$lang"
|
||||||
|
|||||||
Reference in New Issue
Block a user