From 858bf2a3056cdee61a3cc13b7442d374fa5bfcd8 Mon Sep 17 00:00:00 2001 From: Stefan Lange-Hegermann Date: Tue, 21 Oct 2025 16:42:25 +0200 Subject: [PATCH] calculator allows manual entries too --- Cable/CalculatorView.swift | 341 ++++++++++++++++++++++++++++++++----- 1 file changed, 301 insertions(+), 40 deletions(-) diff --git a/Cable/CalculatorView.swift b/Cable/CalculatorView.swift index 6e0e0fa..1633aa2 100644 --- a/Cable/CalculatorView.swift +++ b/Cable/CalculatorView.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftData +import Foundation struct CalculatorView: View { @EnvironmentObject var unitSettings: UnitSystemSettings @@ -17,6 +18,10 @@ struct CalculatorView: View { @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 showingLoadEditor = false @State private var presentedAffiliateLink: AffiliateLinkInfo? @State private var completedItemIDs: Set @@ -32,6 +37,15 @@ struct CalculatorView: View { case voltage, current, power, length } + 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? @@ -123,13 +137,34 @@ struct CalculatorView: View { } .alert("Edit Length", isPresented: Binding( get: { editingValue == .length }, - set: { if !$0 { editingValue = nil } } + set: { isPresented in + if !isPresented { + editingValue = nil + lengthInput = "" + } + } )) { - TextField("Length", value: $calculator.length, format: .number) + TextField("Length", text: $lengthInput) .keyboardType(.decimalPad) - Button("Cancel", role: .cancel) { editingValue = nil } - Button("Save") { + .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() } @@ -138,18 +173,39 @@ struct CalculatorView: View { } .alert("Edit Voltage", isPresented: Binding( get: { editingValue == .voltage }, - set: { if !$0 { editingValue = nil } } + set: { isPresented in + if !isPresented { + editingValue = nil + voltageInput = "" + } + } )) { - TextField("Voltage", value: $calculator.voltage, format: .number) + TextField("Voltage", text: $voltageInput) .keyboardType(.decimalPad) - Button("Cancel", role: .cancel) { editingValue = nil } + .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() } @@ -158,14 +214,35 @@ struct CalculatorView: View { } .alert("Edit Current", isPresented: Binding( get: { editingValue == .current }, - set: { if !$0 { editingValue = nil } } + set: { isPresented in + if !isPresented { + editingValue = nil + currentInput = "" + } + } )) { - TextField("Current", value: $calculator.current, format: .number) + TextField("Current", text: $currentInput) .keyboardType(.decimalPad) - Button("Cancel", role: .cancel) { editingValue = nil } + .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() } @@ -174,14 +251,35 @@ struct CalculatorView: View { } .alert("Edit Power", isPresented: Binding( get: { editingValue == .power }, - set: { if !$0 { editingValue = nil } } + set: { isPresented in + if !isPresented { + editingValue = nil + powerInput = "" + } + } )) { - TextField("Power", value: $calculator.power, format: .number) + TextField("Power", text: $powerInput) .keyboardType(.decimalPad) - Button("Cancel", role: .cancel) { editingValue = nil } + .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() } @@ -331,7 +429,7 @@ struct CalculatorView: View { VStack() { HStack { Button(action: { - editingValue = .voltage + beginVoltageEditing() }) { Text(String(format: "%.1fV", calculator.voltage)) .font(.caption) @@ -405,7 +503,11 @@ struct CalculatorView: View { .frame(maxWidth: .infinity) Button(action: { - editingValue = isWattMode ? .power : .current + if isWattMode { + beginPowerEditing() + } else { + beginCurrentEditing() + } }) { Text(String(format: "%.1fA", calculator.current)) .fontWeight(.semibold) @@ -414,7 +516,11 @@ struct CalculatorView: View { .buttonStyle(.plain) Button(action: { - editingValue = isWattMode ? .current : .power + if isWattMode { + beginCurrentEditing() + } else { + beginPowerEditing() + } }) { Text(String(format: "%.0fW", calculator.calculatedPower)) .font(.caption) @@ -459,7 +565,7 @@ struct CalculatorView: View { Text("•").foregroundColor(.secondary) - Button(action: { editingValue = .length }) { + Button(action: { beginLengthEditing() }) { Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit)) .fontWeight(.medium) .foregroundColor(.primary) @@ -660,14 +766,64 @@ struct CalculatorView: View { } .padding(.horizontal) } + + private var voltageSliderRange: ClosedRange { + let upperBound = max(48, calculator.voltage) + return 0...upperBound + } + + private var powerSliderRange: ClosedRange { + let upperBound = max(2000, calculator.power) + return 0...upperBound + } + + private var currentSliderRange: ClosedRange { + let upperBound = max(100, calculator.current) + return 0...upperBound + } + + private var lengthSliderRange: ClosedRange { + let baseMax = unitSettings.unitSystem == .metric ? 20.0 : 60.0 + let upperBound = max(baseMax, calculator.length) + return 0...upperBound + } + + 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: $calculator.voltage, - range: 3...48, - unit: "V", - tapAction: { editingValue = .voltage }, - snapValues: [3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0]) + 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) { if isWattMode { calculator.updateFromPower() @@ -683,8 +839,17 @@ struct CalculatorView: View { private var currentPowerSlider: some View { if isWattMode { SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"), - value: $calculator.power, - range: 0...2000, + 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: { @@ -692,8 +857,8 @@ struct CalculatorView: View { calculator.updateFromPower() autoUpdateSavedLoad() }, - tapAction: { editingValue = .power }, - snapValues: [5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 100, 125, 150, 200, 250, 300, 400, 500, 600, 750, 1000, 1250, 1500, 2000]) + tapAction: beginPowerEditing, + snapValues: editingValue == .power ? nil : powerSnapValues) .onChange(of: calculator.power) { calculator.updateFromPower() calculator.objectWillChange.send() @@ -701,8 +866,17 @@ struct CalculatorView: View { } } else { SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"), - value: $calculator.current, - range: 0...100, + 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: { @@ -710,8 +884,8 @@ struct CalculatorView: View { calculator.updateFromCurrent() autoUpdateSavedLoad() }, - tapAction: { editingValue = .current }, - snapValues: [0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100]) + tapAction: beginCurrentEditing, + snapValues: editingValue == .current ? nil : currentSnapValues) .onChange(of: calculator.current) { calculator.updateFromCurrent() calculator.objectWillChange.send() @@ -726,17 +900,104 @@ struct CalculatorView: View { comment: "Title format for the cable length slider" ) return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit), - value: $calculator.length, - range: 0...20, + 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: { editingValue = .length }, - snapValues: unitSettings.unitSystem == .metric ? - [0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20] : - [1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60]) - .onChange(of: calculator.length) { - calculator.objectWillChange.send() - autoUpdateSavedLoad() + tapAction: beginLengthEditing, + snapValues: editingValue == .length ? nil : lengthSnapValues) + .onChange(of: calculator.length) { + calculator.objectWillChange.send() + 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 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 }