calculator allows manual entries too

This commit is contained in:
Stefan Lange-Hegermann
2025-10-21 16:42:25 +02:00
parent f171c3d6b2
commit 858bf2a305

View File

@@ -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<String>
@@ -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<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 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
}