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 SwiftUI
import SwiftData import SwiftData
import Foundation
struct CalculatorView: View { struct CalculatorView: View {
@EnvironmentObject var unitSettings: UnitSystemSettings @EnvironmentObject var unitSettings: UnitSystemSettings
@@ -17,6 +18,10 @@ struct CalculatorView: View {
@State private var showingLibrary = false @State private var showingLibrary = false
@State private var isWattMode = false @State private var isWattMode = false
@State private var editingValue: EditingValue? = nil @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 showingLoadEditor = false
@State private var presentedAffiliateLink: AffiliateLinkInfo? @State private var presentedAffiliateLink: AffiliateLinkInfo?
@State private var completedItemIDs: Set<String> @State private var completedItemIDs: Set<String>
@@ -32,6 +37,15 @@ struct CalculatorView: View {
case voltage, current, power, length 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 { struct AffiliateLinkInfo: Identifiable, Equatable {
let id: String let id: String
let affiliateURL: URL? let affiliateURL: URL?
@@ -123,13 +137,34 @@ struct CalculatorView: View {
} }
.alert("Edit Length", isPresented: Binding( .alert("Edit Length", isPresented: Binding(
get: { editingValue == .length }, 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) .keyboardType(.decimalPad)
Button("Cancel", role: .cancel) { editingValue = nil } .onAppear {
Button("Save") { 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 editingValue = nil
lengthInput = ""
}
Button("Save") {
if let parsed = parseInput(lengthInput) {
calculator.length = roundToTenth(parsed)
}
editingValue = nil
lengthInput = ""
calculator.objectWillChange.send() calculator.objectWillChange.send()
autoUpdateSavedLoad() autoUpdateSavedLoad()
} }
@@ -138,18 +173,39 @@ struct CalculatorView: View {
} }
.alert("Edit Voltage", isPresented: Binding( .alert("Edit Voltage", isPresented: Binding(
get: { editingValue == .voltage }, 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) .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") { Button("Save") {
if let parsed = parseInput(voltageInput) {
calculator.voltage = roundToTenth(parsed)
}
editingValue = nil editingValue = nil
if isWattMode { if isWattMode {
calculator.updateFromPower() calculator.updateFromPower()
} else { } else {
calculator.updateFromCurrent() calculator.updateFromCurrent()
} }
voltageInput = ""
calculator.objectWillChange.send() calculator.objectWillChange.send()
autoUpdateSavedLoad() autoUpdateSavedLoad()
} }
@@ -158,14 +214,35 @@ struct CalculatorView: View {
} }
.alert("Edit Current", isPresented: Binding( .alert("Edit Current", isPresented: Binding(
get: { editingValue == .current }, 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) .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") { Button("Save") {
if let parsed = parseInput(currentInput) {
calculator.current = roundToTenth(parsed)
}
editingValue = nil editingValue = nil
calculator.updateFromCurrent() calculator.updateFromCurrent()
currentInput = ""
calculator.objectWillChange.send() calculator.objectWillChange.send()
autoUpdateSavedLoad() autoUpdateSavedLoad()
} }
@@ -174,14 +251,35 @@ struct CalculatorView: View {
} }
.alert("Edit Power", isPresented: Binding( .alert("Edit Power", isPresented: Binding(
get: { editingValue == .power }, 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) .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") { Button("Save") {
if let parsed = parseInput(powerInput) {
calculator.power = roundToNearestFive(parsed)
}
editingValue = nil editingValue = nil
calculator.updateFromPower() calculator.updateFromPower()
powerInput = ""
calculator.objectWillChange.send() calculator.objectWillChange.send()
autoUpdateSavedLoad() autoUpdateSavedLoad()
} }
@@ -331,7 +429,7 @@ struct CalculatorView: View {
VStack() { VStack() {
HStack { HStack {
Button(action: { Button(action: {
editingValue = .voltage beginVoltageEditing()
}) { }) {
Text(String(format: "%.1fV", calculator.voltage)) Text(String(format: "%.1fV", calculator.voltage))
.font(.caption) .font(.caption)
@@ -405,7 +503,11 @@ struct CalculatorView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
Button(action: { Button(action: {
editingValue = isWattMode ? .power : .current if isWattMode {
beginPowerEditing()
} else {
beginCurrentEditing()
}
}) { }) {
Text(String(format: "%.1fA", calculator.current)) Text(String(format: "%.1fA", calculator.current))
.fontWeight(.semibold) .fontWeight(.semibold)
@@ -414,7 +516,11 @@ struct CalculatorView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
Button(action: { Button(action: {
editingValue = isWattMode ? .current : .power if isWattMode {
beginCurrentEditing()
} else {
beginPowerEditing()
}
}) { }) {
Text(String(format: "%.0fW", calculator.calculatedPower)) Text(String(format: "%.0fW", calculator.calculatedPower))
.font(.caption) .font(.caption)
@@ -459,7 +565,7 @@ struct CalculatorView: View {
Text("").foregroundColor(.secondary) Text("").foregroundColor(.secondary)
Button(action: { editingValue = .length }) { Button(action: { beginLengthEditing() }) {
Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit)) Text(String(format: "%.1f %@", calculator.length, unitSettings.unitSystem.lengthUnit))
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(.primary) .foregroundColor(.primary)
@@ -660,14 +766,64 @@ struct CalculatorView: View {
} }
.padding(.horizontal) .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 { private var voltageSlider: some View {
SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"), SliderSection(title: String(localized: "slider.voltage.title", comment: "Title for the voltage slider"),
value: $calculator.voltage, value: Binding(
range: 3...48, get: { calculator.voltage },
unit: "V", set: { newValue in
tapAction: { editingValue = .voltage }, if editingValue == .voltage {
snapValues: [3.3, 5.0, 6.0, 9.0, 12.0, 13.8, 24.0, 28.0, 48.0]) 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) { .onChange(of: calculator.voltage) {
if isWattMode { if isWattMode {
calculator.updateFromPower() calculator.updateFromPower()
@@ -683,8 +839,17 @@ struct CalculatorView: View {
private var currentPowerSlider: some View { private var currentPowerSlider: some View {
if isWattMode { if isWattMode {
SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"), SliderSection(title: String(localized: "slider.power.title", comment: "Title for the power slider"),
value: $calculator.power, value: Binding(
range: 0...2000, get: { calculator.power },
set: { newValue in
if editingValue == .power {
calculator.power = roundToNearestFive(newValue)
} else {
calculator.power = normalizedPower(for: newValue)
}
}
),
range: powerSliderRange,
unit: "W", unit: "W",
buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"), buttonText: String(localized: "slider.button.watt", comment: "Button label when showing power control"),
buttonAction: { buttonAction: {
@@ -692,8 +857,8 @@ struct CalculatorView: View {
calculator.updateFromPower() calculator.updateFromPower()
autoUpdateSavedLoad() autoUpdateSavedLoad()
}, },
tapAction: { editingValue = .power }, tapAction: beginPowerEditing,
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]) snapValues: editingValue == .power ? nil : powerSnapValues)
.onChange(of: calculator.power) { .onChange(of: calculator.power) {
calculator.updateFromPower() calculator.updateFromPower()
calculator.objectWillChange.send() calculator.objectWillChange.send()
@@ -701,8 +866,17 @@ struct CalculatorView: View {
} }
} else { } else {
SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"), SliderSection(title: String(localized: "slider.current.title", comment: "Title for the current slider"),
value: $calculator.current, value: Binding(
range: 0...100, get: { calculator.current },
set: { newValue in
if editingValue == .current {
calculator.current = roundToTenth(newValue)
} else {
calculator.current = normalizedCurrent(for: newValue)
}
}
),
range: currentSliderRange,
unit: "A", unit: "A",
buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"), buttonText: String(localized: "slider.button.ampere", comment: "Button label when showing current control"),
buttonAction: { buttonAction: {
@@ -710,8 +884,8 @@ struct CalculatorView: View {
calculator.updateFromCurrent() calculator.updateFromCurrent()
autoUpdateSavedLoad() autoUpdateSavedLoad()
}, },
tapAction: { editingValue = .current }, tapAction: beginCurrentEditing,
snapValues: [0.5, 1, 2, 3, 4, 5, 7.5, 10, 12, 15, 20, 25, 30, 40, 50, 60, 75, 80, 100]) snapValues: editingValue == .current ? nil : currentSnapValues)
.onChange(of: calculator.current) { .onChange(of: calculator.current) {
calculator.updateFromCurrent() calculator.updateFromCurrent()
calculator.objectWillChange.send() calculator.objectWillChange.send()
@@ -726,17 +900,104 @@ struct CalculatorView: View {
comment: "Title format for the cable length slider" comment: "Title format for the cable length slider"
) )
return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit), return SliderSection(title: String(format: lengthTitleFormat, locale: Locale.current, unitSettings.unitSystem.lengthUnit),
value: $calculator.length, value: Binding(
range: 0...20, 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, unit: unitSettings.unitSystem.lengthUnit,
tapAction: { editingValue = .length }, tapAction: beginLengthEditing,
snapValues: unitSettings.unitSystem == .metric ? snapValues: editingValue == .length ? nil : lengthSnapValues)
[0.5, 1, 1.5, 2, 2.5, 3, 4, 5, 6, 7.5, 10, 12, 15, 20] : .onChange(of: calculator.length) {
[1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 25, 30, 40, 50, 60]) calculator.objectWillChange.send()
.onChange(of: calculator.length) { autoUpdateSavedLoad()
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
} }