1836 lines
66 KiB
Swift
1836 lines
66 KiB
Swift
//
|
|
// CalculatorView.swift
|
|
// Cable
|
|
//
|
|
// Created by Stefan Lange-Hegermann on 11.09.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Foundation
|
|
|
|
struct CalculatorView: View {
|
|
@EnvironmentObject var unitSettings: UnitSystemSettings
|
|
@StateObject private var calculator = CableCalculator()
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Query private var savedLoads: [SavedLoad]
|
|
@State private var showingLibrary = false
|
|
@State private var isWattMode = false
|
|
@State private var editingValue: EditingValue? = nil
|
|
@State private var voltageInput: String = ""
|
|
@State private var currentInput: String = ""
|
|
@State private var powerInput: String = ""
|
|
@State private var lengthInput: String = ""
|
|
@State private var dutyCycleInput: String = ""
|
|
@State private var usageHoursInput: String = ""
|
|
@State private var showingLoadEditor = false
|
|
@State private var showingProUpsell = false
|
|
@State private var presentedAffiliateLink: AffiliateLinkInfo?
|
|
@State private var completedItemIDs: Set<String>
|
|
@State private var isAdvancedExpanded = false
|
|
@State private var hasActiveProSubscription = false
|
|
|
|
let savedLoad: SavedLoad?
|
|
|
|
init(savedLoad: SavedLoad? = nil) {
|
|
self.savedLoad = savedLoad
|
|
_completedItemIDs = State(initialValue: Set(savedLoad?.bomCompletedItemIDs ?? []))
|
|
}
|
|
|
|
enum EditingValue {
|
|
case voltage, current, power, length, dutyCycle, usageHours
|
|
}
|
|
|
|
private static let editFormatter: NumberFormatter = {
|
|
let formatter = NumberFormatter()
|
|
formatter.locale = .current
|
|
formatter.numberStyle = .decimal
|
|
formatter.minimumFractionDigits = 0
|
|
formatter.maximumFractionDigits = 1
|
|
return formatter
|
|
}()
|
|
|
|
struct AffiliateLinkInfo: Identifiable, Equatable {
|
|
let id: String
|
|
let affiliateURL: URL?
|
|
let buttonTitle: String
|
|
let regionName: String?
|
|
let countryCode: String?
|
|
}
|
|
|
|
struct BOMItem: Identifiable, Equatable {
|
|
enum Destination: Equatable {
|
|
case affiliate(URL)
|
|
case amazonSearch(String)
|
|
}
|
|
|
|
let id: String
|
|
let title: String
|
|
let detail: String
|
|
let iconSystemName: String
|
|
let destination: Destination
|
|
let isPrimaryComponent: Bool
|
|
}
|
|
|
|
var body: some View {
|
|
attachAlerts(
|
|
attachSheets(
|
|
navigationWrapped(mainLayout)
|
|
)
|
|
)
|
|
.task {
|
|
hasActiveProSubscription = (await SettingsView.fetchProStatus()) != nil
|
|
}
|
|
}
|
|
|
|
private func attachAlerts<V: View>(_ view: V) -> some View {
|
|
let withLength = addLengthAlert(to: view)
|
|
let withVoltage = addVoltageAlert(to: withLength)
|
|
let withCurrent = addCurrentAlert(to: withVoltage)
|
|
let withPower = addPowerAlert(to: withCurrent)
|
|
let withDutyCycle = addDutyCycleAlert(to: withPower)
|
|
return addUsageHoursAlert(to: withDutyCycle)
|
|
}
|
|
|
|
private func addLengthAlert<V: View>(to view: V) -> some View {
|
|
view
|
|
.alert("Edit Length", isPresented: Binding(
|
|
get: { editingValue == .length },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingValue = nil
|
|
lengthInput = ""
|
|
}
|
|
}
|
|
)) {
|
|
TextField("Length", text: $lengthInput)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if lengthInput.isEmpty {
|
|
lengthInput = formattedValue(calculator.length)
|
|
}
|
|
}
|
|
.onChange(of: lengthInput) { _, newValue in
|
|
guard editingValue == .length, let parsed = parseInput(newValue) else { return }
|
|
calculator.length = roundToTenth(parsed)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
editingValue = nil
|
|
lengthInput = ""
|
|
}
|
|
Button("Save") {
|
|
if let parsed = parseInput(lengthInput) {
|
|
calculator.length = roundToTenth(parsed)
|
|
}
|
|
editingValue = nil
|
|
lengthInput = ""
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter length in \(unitSettings.unitSystem.lengthUnit)")
|
|
}
|
|
}
|
|
|
|
private func addVoltageAlert<V: View>(to view: V) -> some View {
|
|
view
|
|
.alert("Edit Voltage", isPresented: Binding(
|
|
get: { editingValue == .voltage },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingValue = nil
|
|
voltageInput = ""
|
|
}
|
|
}
|
|
)) {
|
|
TextField("Voltage", text: $voltageInput)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if voltageInput.isEmpty {
|
|
voltageInput = formattedValue(calculator.voltage)
|
|
}
|
|
}
|
|
.onChange(of: voltageInput) { _, newValue in
|
|
guard editingValue == .voltage, let parsed = parseInput(newValue) else { return }
|
|
calculator.voltage = roundToTenth(parsed)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
editingValue = nil
|
|
voltageInput = ""
|
|
}
|
|
Button("Save") {
|
|
if let parsed = parseInput(voltageInput) {
|
|
calculator.voltage = roundToTenth(parsed)
|
|
}
|
|
editingValue = nil
|
|
if isWattMode {
|
|
calculator.updateFromPower()
|
|
} else {
|
|
calculator.updateFromCurrent()
|
|
}
|
|
voltageInput = ""
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter voltage in volts (V)")
|
|
}
|
|
}
|
|
|
|
private func addCurrentAlert<V: View>(to view: V) -> some View {
|
|
view
|
|
.alert("Edit Current", isPresented: Binding(
|
|
get: { editingValue == .current },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingValue = nil
|
|
currentInput = ""
|
|
}
|
|
}
|
|
)) {
|
|
TextField("Current", text: $currentInput)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if currentInput.isEmpty {
|
|
currentInput = formattedValue(calculator.current)
|
|
}
|
|
}
|
|
.onChange(of: currentInput) { _, newValue in
|
|
guard editingValue == .current, let parsed = parseInput(newValue) else { return }
|
|
calculator.current = roundToTenth(parsed)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
editingValue = nil
|
|
currentInput = ""
|
|
}
|
|
Button("Save") {
|
|
if let parsed = parseInput(currentInput) {
|
|
calculator.current = roundToTenth(parsed)
|
|
}
|
|
editingValue = nil
|
|
calculator.updateFromCurrent()
|
|
currentInput = ""
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter current in amperes (A)")
|
|
}
|
|
}
|
|
|
|
private func addPowerAlert<V: View>(to view: V) -> some View {
|
|
view
|
|
.alert("Edit Power", isPresented: Binding(
|
|
get: { editingValue == .power },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingValue = nil
|
|
powerInput = ""
|
|
}
|
|
}
|
|
)) {
|
|
TextField("Power", text: $powerInput)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if powerInput.isEmpty {
|
|
powerInput = formattedValue(calculator.power)
|
|
}
|
|
}
|
|
.onChange(of: powerInput) { _, newValue in
|
|
guard editingValue == .power, let parsed = parseInput(newValue) else { return }
|
|
calculator.power = roundToNearestFive(parsed)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
editingValue = nil
|
|
powerInput = ""
|
|
}
|
|
Button("Save") {
|
|
if let parsed = parseInput(powerInput) {
|
|
calculator.power = roundToNearestFive(parsed)
|
|
}
|
|
editingValue = nil
|
|
calculator.updateFromPower()
|
|
powerInput = ""
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text("Enter power in watts (W)")
|
|
}
|
|
}
|
|
|
|
private func addDutyCycleAlert<V: View>(to view: V) -> some View {
|
|
view
|
|
.alert(
|
|
dutyCycleAlertTitle,
|
|
isPresented: Binding(
|
|
get: { editingValue == .dutyCycle },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingValue = nil
|
|
dutyCycleInput = ""
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
TextField(dutyCycleAlertPlaceholder, text: $dutyCycleInput)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if dutyCycleInput.isEmpty {
|
|
dutyCycleInput = formattedValue(calculator.dutyCyclePercent)
|
|
}
|
|
}
|
|
.onChange(of: dutyCycleInput) { _, newValue in
|
|
guard editingValue == .dutyCycle, let parsed = parseInput(newValue) else { return }
|
|
calculator.dutyCyclePercent = clampDutyCyclePercent(parsed)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
editingValue = nil
|
|
dutyCycleInput = ""
|
|
}
|
|
Button("Save") {
|
|
if let parsed = parseInput(dutyCycleInput) {
|
|
calculator.dutyCyclePercent = clampDutyCyclePercent(parsed)
|
|
}
|
|
editingValue = nil
|
|
dutyCycleInput = ""
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text(dutyCycleAlertMessage)
|
|
}
|
|
}
|
|
|
|
private func addUsageHoursAlert<V: View>(to view: V) -> some View {
|
|
view
|
|
.alert(
|
|
usageHoursAlertTitle,
|
|
isPresented: Binding(
|
|
get: { editingValue == .usageHours },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
editingValue = nil
|
|
usageHoursInput = ""
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
TextField(usageHoursAlertPlaceholder, text: $usageHoursInput)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if usageHoursInput.isEmpty {
|
|
usageHoursInput = formattedValue(calculator.dailyUsageHours)
|
|
}
|
|
}
|
|
.onChange(of: usageHoursInput) { _, newValue in
|
|
guard editingValue == .usageHours, let parsed = parseInput(newValue) else { return }
|
|
calculator.dailyUsageHours = clampUsageHours(parsed)
|
|
}
|
|
Button("Cancel", role: .cancel) {
|
|
editingValue = nil
|
|
usageHoursInput = ""
|
|
}
|
|
Button("Save") {
|
|
if let parsed = parseInput(usageHoursInput) {
|
|
calculator.dailyUsageHours = clampUsageHours(parsed)
|
|
}
|
|
editingValue = nil
|
|
usageHoursInput = ""
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} message: {
|
|
Text(usageHoursAlertMessage)
|
|
}
|
|
}
|
|
|
|
private func attachSheets<V: View>(_ view: V) -> some View {
|
|
view
|
|
.sheet(isPresented: $showingLibrary, content: librarySheet)
|
|
.sheet(isPresented: $showingLoadEditor, content: loadEditorSheet)
|
|
.sheet(item: $presentedAffiliateLink, content: billOfMaterialsSheet(info:))
|
|
.sheet(isPresented: $showingProUpsell) {
|
|
CableProPaywallView(isPresented: $showingProUpsell)
|
|
}
|
|
.onAppear {
|
|
if let savedLoad = savedLoad {
|
|
loadConfiguration(from: savedLoad)
|
|
}
|
|
}
|
|
.onChange(of: completedItemIDs) { _, _ in
|
|
persistCompletedItems()
|
|
}
|
|
}
|
|
|
|
private func navigationWrapped<V: View>(_ view: V) -> some View {
|
|
view
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.navigationTitle("")
|
|
.toolbar { toolbarContent }
|
|
}
|
|
|
|
private var mainLayout: some View {
|
|
VStack(spacing: 0) {
|
|
badgesSection
|
|
circuitDiagram
|
|
resultsSection
|
|
mainContent
|
|
}
|
|
}
|
|
|
|
@ToolbarContentBuilder
|
|
private var toolbarContent: some ToolbarContent {
|
|
ToolbarItem(placement: .principal) {
|
|
navigationTitle
|
|
}
|
|
|
|
if savedLoad == nil {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Save") {
|
|
saveCurrentLoad()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View {
|
|
BillOfMaterialsView(
|
|
info: info,
|
|
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL),
|
|
completedItemIDs: $completedItemIDs
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func librarySheet() -> some View {
|
|
LoadLibraryView(calculator: calculator)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func loadEditorSheet() -> some View {
|
|
LoadEditorView(
|
|
loadName: Binding(
|
|
get: { calculator.loadName },
|
|
set: {
|
|
calculator.loadName = $0
|
|
autoUpdateSavedLoad()
|
|
}
|
|
),
|
|
iconName: Binding(
|
|
get: { savedLoad?.iconName ?? "lightbulb" },
|
|
set: { newValue in
|
|
guard let savedLoad else { return }
|
|
savedLoad.iconName = newValue
|
|
savedLoad.remoteIconURLString = nil
|
|
autoUpdateSavedLoad()
|
|
}
|
|
),
|
|
colorName: Binding(
|
|
get: { savedLoad?.colorName ?? "blue" },
|
|
set: {
|
|
savedLoad?.colorName = $0
|
|
autoUpdateSavedLoad()
|
|
}
|
|
),
|
|
remoteIconURLString: Binding(
|
|
get: { savedLoad?.remoteIconURLString },
|
|
set: { newValue in
|
|
guard let savedLoad else { return }
|
|
savedLoad.remoteIconURLString = newValue
|
|
autoUpdateSavedLoad()
|
|
}
|
|
)
|
|
)
|
|
}
|
|
|
|
private var loadIcon: String {
|
|
savedLoad?.iconName ?? "lightbulb"
|
|
}
|
|
|
|
private var loadColor: Color {
|
|
let colorName = savedLoad?.colorName ?? "blue"
|
|
switch colorName {
|
|
case "blue": return .blue
|
|
case "green": return .green
|
|
case "orange": return .orange
|
|
case "red": return .red
|
|
case "purple": return .purple
|
|
case "yellow": return .yellow
|
|
case "pink": return .pink
|
|
case "teal": return .teal
|
|
case "indigo": return .indigo
|
|
case "mint": return .mint
|
|
case "cyan": return .cyan
|
|
case "brown": return .brown
|
|
case "gray": return .gray
|
|
default: return .blue
|
|
}
|
|
}
|
|
|
|
private var loadRemoteIconURLString: String? {
|
|
savedLoad?.remoteIconURLString
|
|
}
|
|
|
|
private var affiliateLinkInfo: AffiliateLinkInfo? {
|
|
guard let savedLoad else { return nil }
|
|
|
|
let affiliateURL: URL?
|
|
if let urlString = savedLoad.affiliateURLString,
|
|
let parsedURL = URL(string: urlString) {
|
|
affiliateURL = parsedURL
|
|
} else {
|
|
affiliateURL = nil
|
|
}
|
|
|
|
let rawCountryCode = savedLoad.affiliateCountryCode ?? Locale.current.region?.identifier
|
|
let countryCode = rawCountryCode?.uppercased()
|
|
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
|
|
|
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
|
|
let identifier = "bom-\(savedLoad.name)-\(savedLoad.timestamp.timeIntervalSince1970)"
|
|
|
|
return AffiliateLinkInfo(
|
|
id: identifier,
|
|
affiliateURL: affiliateURL,
|
|
buttonTitle: buttonTitle,
|
|
regionName: regionName,
|
|
countryCode: countryCode
|
|
)
|
|
}
|
|
|
|
private var navigationTitle: some View {
|
|
Button(action: {
|
|
showingLoadEditor = true
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
LoadIconView(
|
|
remoteIconURLString: loadRemoteIconURLString,
|
|
fallbackSystemName: loadIcon,
|
|
fallbackColor: loadColor,
|
|
size: 24)
|
|
|
|
Text(calculator.loadName)
|
|
.font(.headline)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var badgesSection: some View {
|
|
HStack(spacing: 12) {
|
|
HStack(spacing: 4) {
|
|
Text("FUSE")
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.secondary)
|
|
Text("\(calculator.recommendedFuse)A")
|
|
.font(.subheadline)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.orange)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.orange.opacity(0.1))
|
|
.cornerRadius(6)
|
|
|
|
HStack(spacing: 4) {
|
|
Text("WIRE")
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.secondary)
|
|
Text(String(format: unitSettings.unitSystem == .imperial ?
|
|
"%.1fft @ %.0f AWG" :
|
|
"%.1fm @ %.1fmm²",
|
|
unitSettings.unitSystem == .imperial ? calculator.length * 3.28084 : calculator.length,
|
|
calculator.crossSection(for: unitSettings.unitSystem)))
|
|
.font(.subheadline)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.blue)
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.1))
|
|
.cornerRadius(6)
|
|
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
private var circuitDiagram: some View {
|
|
HStack(spacing: 0) {
|
|
voltageSection
|
|
wiringSection
|
|
loadBox
|
|
}
|
|
.padding()
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
private var voltageSection: some View {
|
|
VStack(spacing: 0) {
|
|
VStack() {
|
|
HStack {
|
|
Button(action: {
|
|
beginVoltageEditing()
|
|
}) {
|
|
Text(String(format: "%.1fV", calculator.voltage))
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: 60)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var wiringSection: some View {
|
|
VStack {
|
|
HStack(alignment: .center, spacing: 2) {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 8, height: 8)
|
|
Rectangle()
|
|
.fill(.red)
|
|
.frame(height: 4)
|
|
.frame(width: 10)
|
|
Rectangle()
|
|
.fill(.green)
|
|
.frame(height: 30)
|
|
.frame(width:80)
|
|
.overlay(
|
|
Text("\(calculator.recommendedFuse)A")
|
|
.foregroundColor(.white)
|
|
.fontWeight(.bold)
|
|
)
|
|
.cornerRadius(6)
|
|
|
|
Rectangle()
|
|
.fill(.red)
|
|
.frame(height: 4)
|
|
.frame(maxWidth: .infinity)
|
|
}.offset(x: 0, y: -11)
|
|
|
|
HStack(alignment: .center, spacing: 2) {
|
|
Circle()
|
|
.fill(Color.primary)
|
|
.frame(width: 8, height: 8)
|
|
|
|
Rectangle()
|
|
.fill(Color.primary)
|
|
.frame(height: 4)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var loadBox: some View {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color.gray.opacity(0.2))
|
|
.frame(width: 120, height: 90)
|
|
.overlay(loadContent)
|
|
.overlay(connectionPoints)
|
|
}
|
|
}
|
|
|
|
private var loadContent: some View {
|
|
VStack {
|
|
Text(calculator.loadName.uppercased())
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Button(action: {
|
|
if isWattMode {
|
|
beginPowerEditing()
|
|
} else {
|
|
beginCurrentEditing()
|
|
}
|
|
}) {
|
|
Text(String(format: "%.1fA", calculator.current))
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Button(action: {
|
|
if isWattMode {
|
|
beginCurrentEditing()
|
|
} else {
|
|
beginPowerEditing()
|
|
}
|
|
}) {
|
|
Text(String(format: "%.0fW", calculator.calculatedPower))
|
|
.font(.caption)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private var connectionPoints: some View {
|
|
VStack {
|
|
HStack {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 8, height: 8)
|
|
.offset(x: -4, y: 17)
|
|
Spacer()
|
|
}
|
|
Spacer()
|
|
HStack {
|
|
Circle()
|
|
.fill(Color.primary)
|
|
.frame(width: 8, height: 8)
|
|
.offset(x: -4, y: -17)
|
|
Spacer()
|
|
}
|
|
}
|
|
.frame(width: 120, height: 80)
|
|
}
|
|
|
|
private var resultsSection: some View {
|
|
HStack {
|
|
Group {
|
|
Button(action: {}) {
|
|
Text(unitSettings.unitSystem == .imperial ?
|
|
String(format: "%.0f AWG", calculator.crossSection(for: unitSettings.unitSystem)) :
|
|
String(format: "%.1f mm²", calculator.crossSection(for: unitSettings.unitSystem)))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.blue)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("•").foregroundColor(.secondary)
|
|
|
|
Button(action: { beginLengthEditing() }) {
|
|
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("•").foregroundColor(.secondary)
|
|
|
|
Button(action: {}) {
|
|
Text(String(format: "%.1fV (%.1f%%)", calculator.voltageDrop(for: unitSettings.unitSystem), calculator.voltageDropPercentage(for: unitSettings.unitSystem)))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(calculator.voltageDropPercentage(for: unitSettings.unitSystem) > 5 ? .orange : .primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text("•").foregroundColor(.secondary)
|
|
|
|
Button(action: {}) {
|
|
Text(String(format: "%.1fW", calculator.powerLoss(for: unitSettings.unitSystem)))
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
private var mainContent: some View {
|
|
List {
|
|
slidersSection
|
|
advancedSettingsSection
|
|
if let info = affiliateLinkInfo {
|
|
affiliateLinkSection(info: info)
|
|
.listRowBackground(Color.clear)
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollIndicators(.hidden)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color(.systemGroupedBackground))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func affiliateLinkSection(info: AffiliateLinkInfo) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Button {
|
|
presentedAffiliateLink = info
|
|
} label: {
|
|
Label(info.buttonTitle, systemImage: "cart")
|
|
.font(.callout.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.padding(.horizontal, 16)
|
|
.foregroundColor(.accentColor)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(Color.accentColor.opacity(0.12))
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
let descriptionKey = info.affiliateURL != nil
|
|
? "affiliate.description.with_link"
|
|
: "affiliate.description.without_link"
|
|
let description = NSLocalizedString(
|
|
descriptionKey,
|
|
comment: "Explanation text beneath the affiliate button"
|
|
)
|
|
Text(description)
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding()
|
|
.background(Color(.secondarySystemBackground))
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
}
|
|
|
|
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] {
|
|
let unitSystem = unitSettings.unitSystem
|
|
let lengthValue = calculator.length
|
|
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
|
|
|
let crossSectionValue = calculator.crossSection(for: unitSystem)
|
|
let crossSectionLabel: String
|
|
let unknownSizeLabel = String(localized: "bom.size.unknown", comment: "Fallback label when the cable size is unknown")
|
|
if unitSystem == .imperial {
|
|
if crossSectionValue > 0 {
|
|
crossSectionLabel = String(format: "AWG %.0f", crossSectionValue)
|
|
} else {
|
|
crossSectionLabel = unknownSizeLabel
|
|
}
|
|
} else {
|
|
if crossSectionValue > 0 {
|
|
crossSectionLabel = String(format: "%.1f mm²", crossSectionValue)
|
|
} else {
|
|
crossSectionLabel = unknownSizeLabel
|
|
}
|
|
}
|
|
|
|
let cableDetail = "\(lengthLabel) • \(crossSectionLabel)"
|
|
let powerDetail = String(format: "%.0f W @ %.1f V", calculator.calculatedPower, calculator.voltage)
|
|
|
|
let fuseRating = calculator.recommendedFuse
|
|
let fuseDetailFormat = NSLocalizedString(
|
|
"bom.fuse.detail",
|
|
comment: "Description for the fuse entry in the calculator BOM"
|
|
)
|
|
let fuseDetail = String.localizedStringWithFormat(fuseDetailFormat, fuseRating)
|
|
|
|
let cableShoesDetailFormat = NSLocalizedString(
|
|
"bom.terminals.detail",
|
|
comment: "Description for the cable terminals entry in the calculator BOM"
|
|
)
|
|
let cableShoesDetail = String.localizedStringWithFormat(cableShoesDetailFormat, crossSectionLabel.lowercased())
|
|
|
|
let cableGaugeQuery: String
|
|
if unitSystem == .imperial {
|
|
cableGaugeQuery = String(format: "AWG %.0f", crossSectionValue)
|
|
} else {
|
|
cableGaugeQuery = String(format: "%.1f mm2", crossSectionValue)
|
|
}
|
|
|
|
let redCableQuery = "\(cableGaugeQuery) red battery cable"
|
|
let blackCableQuery = "\(cableGaugeQuery) black battery cable"
|
|
let fuseQuery = "inline fuse holder \(fuseRating)A"
|
|
let terminalQuery = "\(cableGaugeQuery) cable shoes"
|
|
let deviceQueryBase = calculator.loadName.isEmpty
|
|
? String(format: "DC device %.0fW %.0fV", calculator.calculatedPower, calculator.voltage)
|
|
: calculator.loadName
|
|
|
|
var items: [BOMItem] = []
|
|
|
|
let fallbackComponentTitle = String(localized: "component.fallback.name", comment: "Fallback name for a component when no custom name is provided")
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "component",
|
|
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
|
|
detail: powerDetail,
|
|
iconSystemName: "bolt.fill",
|
|
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase),
|
|
isPrimaryComponent: true
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "cable-red",
|
|
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
|
detail: cableDetail,
|
|
iconSystemName: "bolt.horizontal.circle",
|
|
destination: .amazonSearch(redCableQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "cable-black",
|
|
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
|
detail: cableDetail,
|
|
iconSystemName: "bolt.horizontal.circle",
|
|
destination: .amazonSearch(blackCableQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "fuse",
|
|
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
|
|
detail: fuseDetail,
|
|
iconSystemName: "bolt.shield",
|
|
destination: .amazonSearch(fuseQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
items.append(
|
|
BOMItem(
|
|
id: "terminals",
|
|
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
|
|
detail: cableShoesDetail,
|
|
iconSystemName: "wrench.and.screwdriver",
|
|
destination: .amazonSearch(terminalQuery),
|
|
isPrimaryComponent: false
|
|
)
|
|
)
|
|
|
|
return items
|
|
}
|
|
|
|
private var advancedSettingsTitle: String {
|
|
String(
|
|
localized: "calculator.advanced.section.title",
|
|
comment: "Title for the advanced load settings section"
|
|
)
|
|
}
|
|
|
|
private var dutyCycleTitle: String {
|
|
String(
|
|
localized: "calculator.advanced.duty_cycle.title",
|
|
comment: "Title for the duty cycle slider"
|
|
)
|
|
}
|
|
|
|
private var dutyCycleHelperText: String {
|
|
String(
|
|
localized: "calculator.advanced.duty_cycle.helper",
|
|
comment: "Helper text explaining duty cycle"
|
|
)
|
|
}
|
|
|
|
private var usageHoursTitle: String {
|
|
String(
|
|
localized: "calculator.advanced.usage_hours.title",
|
|
comment: "Title for the daily usage slider"
|
|
)
|
|
}
|
|
|
|
private var usageHoursHelperText: String {
|
|
String(
|
|
localized: "calculator.advanced.usage_hours.helper",
|
|
comment: "Helper text explaining daily usage hours"
|
|
)
|
|
}
|
|
|
|
private var dutyCycleAlertTitle: String {
|
|
String(
|
|
localized: "calculator.alert.duty_cycle.title",
|
|
comment: "Title for the duty cycle edit alert"
|
|
)
|
|
}
|
|
|
|
private var dutyCycleAlertPlaceholder: String {
|
|
String(
|
|
localized: "calculator.alert.duty_cycle.placeholder",
|
|
comment: "Placeholder for the duty cycle alert text field"
|
|
)
|
|
}
|
|
|
|
private var dutyCycleAlertMessage: String {
|
|
String(
|
|
localized: "calculator.alert.duty_cycle.message",
|
|
comment: "Helper message for the duty cycle alert"
|
|
)
|
|
}
|
|
|
|
private var usageHoursAlertTitle: String {
|
|
String(
|
|
localized: "calculator.alert.usage_hours.title",
|
|
comment: "Title for the daily usage edit alert"
|
|
)
|
|
}
|
|
|
|
private var usageHoursAlertPlaceholder: String {
|
|
String(
|
|
localized: "calculator.alert.usage_hours.placeholder",
|
|
comment: "Placeholder for the daily usage alert text field"
|
|
)
|
|
}
|
|
|
|
private var usageHoursAlertMessage: String {
|
|
String(
|
|
localized: "calculator.alert.usage_hours.message",
|
|
comment: "Helper message for the daily usage alert"
|
|
)
|
|
}
|
|
|
|
private var advancedFeaturesEnabled: Bool {
|
|
unitSettings.isProUnlocked || hasActiveProSubscription
|
|
}
|
|
|
|
private var slidersSection: some View {
|
|
Section {
|
|
voltageSlider
|
|
.listRowSeparator(.hidden)
|
|
currentPowerSlider
|
|
.listRowSeparator(.hidden)
|
|
lengthSlider
|
|
.listRowSeparator(.hidden)
|
|
}
|
|
.listRowBackground(Color(.systemBackground))
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
|
}
|
|
|
|
private var advancedSettingsSection: some View {
|
|
let advancedEnabled = advancedFeaturesEnabled
|
|
|
|
return Section {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isAdvancedExpanded.toggle()
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Text(advancedSettingsTitle.uppercased())
|
|
.font(.caption2)
|
|
.fontWeight(.medium)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
Image(systemName: isAdvancedExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 10)
|
|
.opacity(advancedEnabled ? 1 : 0.5)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.listRowBackground(Color(.secondarySystemBackground))
|
|
.listRowSeparator(.hidden)
|
|
|
|
if isAdvancedExpanded {
|
|
if !advancedEnabled {
|
|
upgradeToProCTA
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color(.systemBackground))
|
|
}
|
|
dutyCycleSlider
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color(.systemBackground))
|
|
.opacity(advancedEnabled ? 1 : 0.35)
|
|
.allowsHitTesting(advancedEnabled)
|
|
usageHoursSlider
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color(.systemBackground))
|
|
.opacity(advancedEnabled ? 1 : 0.35)
|
|
.allowsHitTesting(advancedEnabled)
|
|
}
|
|
} footer: {
|
|
if isAdvancedExpanded {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(dutyCycleHelperText)
|
|
Text(usageHoursHelperText)
|
|
}
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.opacity(advancedEnabled ? 1 : 0.35)
|
|
}
|
|
}
|
|
.listRowSeparator(.hidden)
|
|
.listRowInsets(EdgeInsets(top: 12, leading: 18, bottom: 12, trailing: 18))
|
|
.animation(.easeInOut(duration: 0.2), value: isAdvancedExpanded)
|
|
}
|
|
|
|
private var upgradeToProCTA: some View {
|
|
Button {
|
|
showingProUpsell = true
|
|
} label: {
|
|
Text("Get Cable Pro")
|
|
.font(.callout.weight(.semibold))
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 6)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
|
|
private var voltageSliderRange: ClosedRange<Double> {
|
|
let upperBound = max(48, calculator.voltage)
|
|
return 0...upperBound
|
|
}
|
|
|
|
private var powerSliderRange: ClosedRange<Double> {
|
|
let upperBound = max(2000, calculator.power)
|
|
return 0...upperBound
|
|
}
|
|
|
|
private var currentSliderRange: ClosedRange<Double> {
|
|
let upperBound = max(100, calculator.current)
|
|
return 0...upperBound
|
|
}
|
|
|
|
private var lengthSliderRange: ClosedRange<Double> {
|
|
let baseMax = unitSettings.unitSystem == .metric ? 20.0 : 60.0
|
|
let upperBound = max(baseMax, calculator.length)
|
|
return 0...upperBound
|
|
}
|
|
|
|
private var dutyCycleRange: ClosedRange<Double> {
|
|
0...100
|
|
}
|
|
|
|
private var usageHoursRange: ClosedRange<Double> {
|
|
0...24
|
|
}
|
|
|
|
private var voltageSnapValues: [Double] {
|
|
[3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0]
|
|
}
|
|
|
|
private var powerSnapValues: [Double] {
|
|
[5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000]
|
|
}
|
|
|
|
private var currentSnapValues: [Double] {
|
|
[0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100]
|
|
}
|
|
|
|
private var lengthSnapValues: [Double] {
|
|
if unitSettings.unitSystem == .metric {
|
|
return [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20]
|
|
} else {
|
|
return [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60]
|
|
}
|
|
}
|
|
|
|
private var voltageSlider: some View {
|
|
SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"),
|
|
value: Binding(
|
|
get: { calculator.voltage },
|
|
set: { newValue in
|
|
if editingValue == .voltage {
|
|
calculator.voltage = roundToTenth(newValue)
|
|
} else {
|
|
calculator.voltage = normalizedVoltage(for: newValue)
|
|
}
|
|
}
|
|
),
|
|
range: voltageSliderRange,
|
|
unit: "V",
|
|
tapAction: beginVoltageEditing,
|
|
snapValues: editingValue == .voltage ? nil : voltageSnapValues)
|
|
.onChange(of: calculator.voltage) { _, _ in
|
|
if isWattMode {
|
|
calculator.updateFromPower()
|
|
} else {
|
|
calculator.updateFromCurrent()
|
|
}
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var currentPowerSlider: some View {
|
|
if isWattMode {
|
|
SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"),
|
|
value: Binding(
|
|
get: { calculator.power },
|
|
set: { newValue in
|
|
if editingValue == .power {
|
|
calculator.power = roundToNearestFive(newValue)
|
|
} else {
|
|
calculator.power = normalizedPower(for: newValue)
|
|
}
|
|
}
|
|
),
|
|
range: powerSliderRange,
|
|
unit: "W",
|
|
buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"),
|
|
buttonAction: {
|
|
isWattMode = false
|
|
calculator.updateFromPower()
|
|
autoUpdateSavedLoad()
|
|
},
|
|
tapAction: beginPowerEditing,
|
|
snapValues: editingValue == .power ? nil : powerSnapValues)
|
|
.onChange(of: calculator.power) { _, _ in
|
|
calculator.updateFromPower()
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
} else {
|
|
SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"),
|
|
value: Binding(
|
|
get: { calculator.current },
|
|
set: { newValue in
|
|
if editingValue == .current {
|
|
calculator.current = roundToTenth(newValue)
|
|
} else {
|
|
calculator.current = normalizedCurrent(for: newValue)
|
|
}
|
|
}
|
|
),
|
|
range: currentSliderRange,
|
|
unit: "A",
|
|
buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"),
|
|
buttonAction: {
|
|
isWattMode = true
|
|
calculator.updateFromCurrent()
|
|
autoUpdateSavedLoad()
|
|
},
|
|
tapAction: beginCurrentEditing,
|
|
snapValues: editingValue == .current ? nil : currentSnapValues)
|
|
.onChange(of: calculator.current) { _, _ in
|
|
calculator.updateFromCurrent()
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var lengthSlider: some View {
|
|
let lengthTitleFormat = NSLocalizedString(
|
|
"slider.length.title",
|
|
comment: "Title format for the cable length slider"
|
|
)
|
|
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
|
|
value: Binding(
|
|
get: { calculator.length },
|
|
set: { newValue in
|
|
if editingValue == .length {
|
|
calculator.length = roundToTenth(newValue)
|
|
} else {
|
|
calculator.length = normalizedLength(for: newValue)
|
|
}
|
|
}
|
|
),
|
|
range: lengthSliderRange,
|
|
unit: unitSettings.unitSystem.lengthUnit,
|
|
tapAction: beginLengthEditing,
|
|
snapValues: editingValue == .length ? nil : lengthSnapValues)
|
|
.onChange(of: calculator.length) { _, _ in
|
|
calculator.objectWillChange.send()
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
|
|
private var dutyCycleSlider: some View {
|
|
SliderSection(
|
|
title: dutyCycleTitle,
|
|
value: Binding(
|
|
get: { calculator.dutyCyclePercent },
|
|
set: { newValue in
|
|
let clamped = clampDutyCyclePercent(newValue)
|
|
calculator.dutyCyclePercent = clamped
|
|
}
|
|
),
|
|
range: dutyCycleRange,
|
|
unit: "%",
|
|
tapAction: beginDutyCycleEditing
|
|
)
|
|
.onChange(of: calculator.dutyCyclePercent) { _, newValue in
|
|
let clamped = clampDutyCyclePercent(newValue)
|
|
if abs(clamped - newValue) > 0.0001 {
|
|
calculator.dutyCyclePercent = clamped
|
|
}
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
|
|
private var usageHoursSlider: some View {
|
|
SliderSection(
|
|
title: usageHoursTitle,
|
|
value: Binding(
|
|
get: { calculator.dailyUsageHours },
|
|
set: { newValue in
|
|
let clamped = clampUsageHours(newValue)
|
|
calculator.dailyUsageHours = clamped
|
|
}
|
|
),
|
|
range: usageHoursRange,
|
|
unit: String(localized: "calculator.advanced.usage_hours.unit", comment: "Unit label for usage hours slider"),
|
|
tapAction: beginUsageHoursEditing
|
|
)
|
|
.onChange(of: calculator.dailyUsageHours) { _, newValue in
|
|
let clamped = clampUsageHours(newValue)
|
|
if abs(clamped - newValue) > 0.0001 {
|
|
calculator.dailyUsageHours = clamped
|
|
}
|
|
autoUpdateSavedLoad()
|
|
}
|
|
}
|
|
|
|
private func normalizedVoltage(for value: Double) -> Double {
|
|
let rounded = roundToTenth(value)
|
|
if let snap = nearestValue(to: rounded, in: voltageSnapValues, tolerance: 0.3) {
|
|
return snap
|
|
}
|
|
return rounded
|
|
}
|
|
|
|
private func normalizedCurrent(for value: Double) -> Double {
|
|
let rounded = roundToTenth(value)
|
|
if let snap = nearestValue(to: rounded, in: currentSnapValues, tolerance: 0.3) {
|
|
return snap
|
|
}
|
|
return rounded
|
|
}
|
|
|
|
private func normalizedPower(for value: Double) -> Double {
|
|
if let snap = nearestValue(to: value, in: powerSnapValues, tolerance: 2.5) {
|
|
return snap
|
|
}
|
|
return roundToNearestFive(value)
|
|
}
|
|
|
|
private func normalizedLength(for value: Double) -> Double {
|
|
let rounded = roundToTenth(value)
|
|
if let snap = nearestValue(to: rounded, in: lengthSnapValues, tolerance: 0.5) {
|
|
return snap
|
|
}
|
|
return rounded
|
|
}
|
|
|
|
private func clampDutyCyclePercent(_ value: Double) -> Double {
|
|
min(100, max(0, roundToTenth(value)))
|
|
}
|
|
|
|
private func clampUsageHours(_ value: Double) -> Double {
|
|
min(24, max(0, roundToTenth(value)))
|
|
}
|
|
|
|
private func nearestValue(to value: Double, in options: [Double], tolerance: Double) -> Double? {
|
|
guard let closest = options.min(by: { abs($0 - value) < abs($1 - value) }) else { return nil }
|
|
return abs(closest - value) <= tolerance ? closest : nil
|
|
}
|
|
|
|
private func roundToTenth(_ value: Double) -> Double {
|
|
max(0, (value * 10).rounded() / 10)
|
|
}
|
|
|
|
private func roundToNearestFive(_ value: Double) -> Double {
|
|
max(0, (value / 5).rounded() * 5)
|
|
}
|
|
|
|
private func formattedValue(_ value: Double) -> String {
|
|
Self.editFormatter.string(from: NSNumber(value: roundToTenth(value))) ?? String(format: "%.1f", value)
|
|
}
|
|
|
|
private func parseInput(_ text: String) -> Double? {
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return nil }
|
|
if let number = Self.editFormatter.number(from: trimmed)?.doubleValue {
|
|
return number
|
|
}
|
|
let decimalSeparator = Locale.current.decimalSeparator ?? "."
|
|
let altSeparator = decimalSeparator == "." ? "," : "."
|
|
let normalized = trimmed.replacingOccurrences(of: altSeparator, with: decimalSeparator)
|
|
return Self.editFormatter.number(from: normalized)?.doubleValue
|
|
}
|
|
|
|
private func beginVoltageEditing() {
|
|
voltageInput = formattedValue(calculator.voltage)
|
|
editingValue = .voltage
|
|
}
|
|
|
|
private func beginCurrentEditing() {
|
|
currentInput = formattedValue(calculator.current)
|
|
editingValue = .current
|
|
}
|
|
|
|
private func beginPowerEditing() {
|
|
powerInput = formattedValue(calculator.power)
|
|
editingValue = .power
|
|
}
|
|
|
|
private func beginLengthEditing() {
|
|
lengthInput = formattedValue(calculator.length)
|
|
editingValue = .length
|
|
}
|
|
|
|
private func beginDutyCycleEditing() {
|
|
dutyCycleInput = formattedValue(calculator.dutyCyclePercent)
|
|
editingValue = .dutyCycle
|
|
}
|
|
|
|
private func beginUsageHoursEditing() {
|
|
usageHoursInput = formattedValue(calculator.dailyUsageHours)
|
|
editingValue = .usageHours
|
|
}
|
|
|
|
|
|
private func saveCurrentLoad() {
|
|
let fallbackName = String(localized: "default.load.unnamed", comment: "Fallback name for a load when no name is provided")
|
|
let savedLoad = SavedLoad(
|
|
name: calculator.loadName.isEmpty ? fallbackName : calculator.loadName,
|
|
voltage: calculator.voltage,
|
|
current: calculator.current,
|
|
power: calculator.power,
|
|
length: calculator.length,
|
|
crossSection: calculator.crossSection(for: .metric), // Always save in metric
|
|
iconName: "lightbulb",
|
|
colorName: "blue",
|
|
isWattMode: isWattMode,
|
|
dutyCyclePercent: calculator.dutyCyclePercent,
|
|
dailyUsageHours: calculator.dailyUsageHours,
|
|
system: nil, // For now, new loads aren't associated with a system
|
|
remoteIconURLString: nil
|
|
)
|
|
modelContext.insert(savedLoad)
|
|
}
|
|
|
|
private func loadConfiguration(from savedLoad: SavedLoad) {
|
|
calculator.loadName = savedLoad.name
|
|
calculator.voltage = savedLoad.voltage
|
|
calculator.current = savedLoad.current
|
|
calculator.power = savedLoad.power
|
|
calculator.length = savedLoad.length
|
|
calculator.dutyCyclePercent = savedLoad.dutyCyclePercent
|
|
calculator.dailyUsageHours = savedLoad.dailyUsageHours
|
|
isWattMode = savedLoad.isWattMode
|
|
completedItemIDs = Set(savedLoad.bomCompletedItemIDs)
|
|
}
|
|
|
|
private func autoUpdateSavedLoad() {
|
|
guard let savedLoad = savedLoad else { return }
|
|
|
|
let fallbackName = String(localized: "default.load.unnamed", comment: "Fallback name for a load when no name is provided")
|
|
savedLoad.name = calculator.loadName.isEmpty ? fallbackName : calculator.loadName
|
|
savedLoad.voltage = calculator.voltage
|
|
savedLoad.current = calculator.current
|
|
savedLoad.power = calculator.power
|
|
savedLoad.length = calculator.length
|
|
savedLoad.crossSection = calculator.crossSection(for: .metric)
|
|
savedLoad.timestamp = Date()
|
|
savedLoad.isWattMode = isWattMode
|
|
savedLoad.dutyCyclePercent = calculator.dutyCyclePercent
|
|
savedLoad.dailyUsageHours = calculator.dailyUsageHours
|
|
savedLoad.bomCompletedItemIDs = Array(completedItemIDs).sorted()
|
|
// Icon and color are updated directly through bindings in the editor
|
|
}
|
|
|
|
private func persistCompletedItems() {
|
|
guard let savedLoad = savedLoad else { return }
|
|
savedLoad.bomCompletedItemIDs = Array(completedItemIDs).sorted()
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting Views
|
|
|
|
private struct BillOfMaterialsView: View {
|
|
let info: CalculatorView.AffiliateLinkInfo
|
|
let items: [CalculatorView.BOMItem]
|
|
@Binding var completedItemIDs: Set<String>
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
@State private var suppressRowTapForID: String?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
Section("Components") {
|
|
ForEach(items) { item in
|
|
let isCompleted = completedItemIDs.contains(item.id)
|
|
let destinationURL = destinationURL(for: item)
|
|
let accessibilityLabel: String = {
|
|
if isCompleted {
|
|
let format = NSLocalizedString(
|
|
"bom.accessibility.mark.incomplete",
|
|
comment: "Accessibility label to mark a BOM item incomplete"
|
|
)
|
|
return String.localizedStringWithFormat(format, item.title)
|
|
} else {
|
|
let format = NSLocalizedString(
|
|
"bom.accessibility.mark.complete",
|
|
comment: "Accessibility label to mark a BOM item complete"
|
|
)
|
|
return String.localizedStringWithFormat(format, item.title)
|
|
}
|
|
}()
|
|
|
|
HStack(spacing: 12) {
|
|
Image(systemName: isCompleted ? "checkmark.circle.fill" : "circle")
|
|
.foregroundColor(isCompleted ? .accentColor : .secondary)
|
|
.imageScale(.large)
|
|
.onTapGesture {
|
|
if isCompleted {
|
|
completedItemIDs.remove(item.id)
|
|
} else {
|
|
completedItemIDs.insert(item.id)
|
|
}
|
|
suppressRowTapForID = item.id
|
|
}
|
|
.accessibilityLabel(accessibilityLabel)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(item.title)
|
|
.font(.headline)
|
|
.foregroundStyle(isCompleted ? Color.primary.opacity(0.7) : Color.primary)
|
|
.strikethrough(isCompleted, color: .accentColor.opacity(0.6))
|
|
|
|
if item.isPrimaryComponent {
|
|
Text(String(localized: "component.fallback.name", comment: "Tag label marking a BOM entry as the main component"))
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundColor(.accentColor)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(Color.accentColor.opacity(0.15), in: Capsule())
|
|
}
|
|
|
|
Text(item.detail)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
if destinationURL != nil {
|
|
Image(systemName: "arrow.up.right")
|
|
.font(.footnote.weight(.semibold))
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.padding(.vertical, 10)
|
|
.contentShape(Rectangle())
|
|
.listRowInsets(EdgeInsets(top: 6, leading: 20, bottom: 6, trailing: 16))
|
|
.listRowBackground(
|
|
Color(.secondarySystemGroupedBackground)
|
|
)
|
|
.onTapGesture {
|
|
if suppressRowTapForID == item.id {
|
|
suppressRowTapForID = nil
|
|
return
|
|
}
|
|
if let destinationURL {
|
|
openURL(destinationURL)
|
|
}
|
|
completedItemIDs.insert(item.id)
|
|
suppressRowTapForID = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Text(
|
|
String(
|
|
localized: "affiliate.disclaimer",
|
|
comment: "Footer note reminding users that affiliate purchases may support the app"
|
|
)
|
|
)
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.navigationTitle(
|
|
String(
|
|
localized: "bom.navigation.title",
|
|
comment: "Navigation title for the bill of materials view"
|
|
)
|
|
)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
|
|
switch item.destination {
|
|
case .affiliate(let url):
|
|
return url
|
|
case .amazonSearch(let query):
|
|
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SliderSection: View {
|
|
let title: String
|
|
@Binding var value: Double
|
|
let range: ClosedRange<Double>
|
|
let unit: String
|
|
var buttonText: String?
|
|
var buttonAction: (() -> Void)?
|
|
var tapAction: (() -> Void)?
|
|
var snapValues: [Double]?
|
|
var isButtonVisible: Bool
|
|
|
|
init(
|
|
title: String,
|
|
value: Binding<Double>,
|
|
range: ClosedRange<Double>,
|
|
unit: String,
|
|
buttonText: String? = nil,
|
|
buttonAction: (() -> Void)? = nil,
|
|
tapAction: (() -> Void)? = nil,
|
|
snapValues: [Double]? = nil,
|
|
isButtonVisible: Bool = true
|
|
) {
|
|
self.title = title
|
|
self._value = value
|
|
self.range = range
|
|
self.unit = unit
|
|
self.buttonText = buttonText
|
|
self.buttonAction = buttonAction
|
|
self.tapAction = tapAction
|
|
self.snapValues = snapValues
|
|
self.isButtonVisible = isButtonVisible
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text(title)
|
|
.font(.headline)
|
|
Spacer()
|
|
if let buttonText = buttonText {
|
|
Button(buttonText) {
|
|
buttonAction?()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.opacity(isButtonVisible ? 1 : 0)
|
|
.allowsHitTesting(isButtonVisible)
|
|
.accessibilityHidden(!isButtonVisible)
|
|
.disabled(!isButtonVisible || buttonAction == nil)
|
|
}
|
|
}
|
|
|
|
Button(action: {
|
|
tapAction?()
|
|
}) {
|
|
Text(String(format: "%.1f %@", value, unit))
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(tapAction == nil)
|
|
|
|
HStack {
|
|
Text(String(format: "%.0f%@", range.lowerBound, unit))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Slider(value: $value, in: range)
|
|
.onChange(of: value) { _, newValue in
|
|
// Always round to 1 decimal place first
|
|
var adjusted = (newValue * 10).rounded() / 10
|
|
|
|
if let snapValues = snapValues {
|
|
// Find the closest snap value
|
|
if let closest = snapValues.min(by: { abs($0 - adjusted) < abs($1 - adjusted) }),
|
|
abs(closest - adjusted) < 0.5 {
|
|
adjusted = closest
|
|
}
|
|
}
|
|
|
|
if abs(adjusted - newValue) > 0.000001 {
|
|
value = adjusted
|
|
}
|
|
}
|
|
|
|
Text(String(format: "%.0f%@", range.upperBound, unit))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct EditableSliderRow: View {
|
|
struct AlertCopy {
|
|
let title: String
|
|
let placeholder: String
|
|
let message: String
|
|
let cancelTitle: String
|
|
let saveTitle: String
|
|
}
|
|
|
|
let title: String
|
|
let unit: String
|
|
let range: ClosedRange<Double>
|
|
@Binding var value: Double
|
|
var buttonText: String? = nil
|
|
var buttonAction: (() -> Void)? = nil
|
|
var isButtonVisible: Bool = true
|
|
var snapValues: [Double]? = nil
|
|
var sliderTransform: (Double) -> Double = { $0 }
|
|
var alertTransform: (Double) -> Double = { $0 }
|
|
var formatValue: (Double) -> String
|
|
var parseInput: (String) -> Double?
|
|
var alertCopy: AlertCopy
|
|
|
|
@State private var isPresentingAlert = false
|
|
@State private var inputText: String = ""
|
|
|
|
var body: some View {
|
|
SliderSection(
|
|
title: title,
|
|
value: Binding(
|
|
get: { value },
|
|
set: { newValue in
|
|
let transformed = sliderTransform(newValue)
|
|
let clamped = clampedValue(transformed)
|
|
value = clamped
|
|
}
|
|
),
|
|
range: range,
|
|
unit: unit,
|
|
buttonText: buttonText,
|
|
buttonAction: buttonAction,
|
|
tapAction: beginEditing,
|
|
snapValues: isPresentingAlert ? nil : snapValues,
|
|
isButtonVisible: isButtonVisible
|
|
)
|
|
.alert(
|
|
alertCopy.title,
|
|
isPresented: $isPresentingAlert
|
|
) {
|
|
TextField(alertCopy.placeholder, text: $inputText)
|
|
.keyboardType(.decimalPad)
|
|
.onAppear {
|
|
if inputText.isEmpty {
|
|
inputText = formatValue(value)
|
|
}
|
|
}
|
|
.onChange(of: inputText) { _, newValue in
|
|
guard let parsed = parseInput(newValue) else { return }
|
|
value = clampedAlertValue(parsed)
|
|
}
|
|
|
|
Button(alertCopy.cancelTitle, role: .cancel) {
|
|
inputText = ""
|
|
}
|
|
|
|
Button(alertCopy.saveTitle) {
|
|
if let parsed = parseInput(inputText) {
|
|
value = clampedAlertValue(parsed)
|
|
}
|
|
inputText = ""
|
|
}
|
|
} message: {
|
|
Text(alertCopy.message)
|
|
}
|
|
}
|
|
|
|
private func beginEditing() {
|
|
inputText = formatValue(value)
|
|
isPresentingAlert = true
|
|
}
|
|
|
|
private func clampedValue(_ newValue: Double) -> Double {
|
|
min(max(range.lowerBound, newValue), range.upperBound)
|
|
}
|
|
|
|
private func clampedAlertValue(_ rawValue: Double) -> Double {
|
|
let transformed = alertTransform(rawValue)
|
|
return clampedValue(transformed)
|
|
}
|
|
}
|
|
|
|
struct LoadLibraryView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.modelContext) private var modelContext
|
|
@Query private var savedLoads: [SavedLoad]
|
|
let calculator: CableCalculator
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
ForEach(savedLoads) { load in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(load.name)
|
|
.fontWeight(.medium)
|
|
HStack {
|
|
Text(String(format: "%.1fV", load.voltage))
|
|
Text("•")
|
|
Text(String(format: "%.1fA", load.current))
|
|
Text("•")
|
|
Text(String(format: "%.1fm", load.length))
|
|
Spacer()
|
|
Text(load.timestamp, format: .dateTime.month().day())
|
|
.foregroundColor(.secondary)
|
|
.font(.caption)
|
|
}
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
loadConfiguration(from: load)
|
|
dismiss()
|
|
}
|
|
}
|
|
.onDelete(perform: deleteLoads)
|
|
}
|
|
.navigationTitle("Load Library")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
EditButton()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadConfiguration(from savedLoad: SavedLoad) {
|
|
calculator.loadName = savedLoad.name
|
|
calculator.voltage = savedLoad.voltage
|
|
calculator.current = savedLoad.current
|
|
calculator.power = savedLoad.power
|
|
calculator.length = savedLoad.length
|
|
calculator.dutyCyclePercent = savedLoad.dutyCyclePercent
|
|
calculator.dailyUsageHours = savedLoad.dailyUsageHours
|
|
}
|
|
|
|
private func deleteLoads(offsets: IndexSet) {
|
|
for index in offsets {
|
|
modelContext.delete(savedLoads[index])
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CalculatorView()
|
|
.modelContainer(for: SavedLoad.self, inMemory: true)
|
|
.environmentObject(UnitSystemSettings())
|
|
}
|